@graineai/inapp-react-native 0.22.0 → 0.24.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 +104 -0
- package/dist/client.d.ts +15 -2
- package/dist/client.js +63 -21
- package/dist/friction.d.ts +70 -0
- package/dist/friction.js +258 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/react-native/index.d.ts +21 -1
- package/dist/react-native/index.js +22 -2
- package/dist/react-native/permissions.d.ts +5 -0
- package/dist/react-native/permissions.js +21 -0
- package/dist/react-native/voice-launcher.js +5 -21
- package/package.json +1 -1
package/INTEGRATION.md
CHANGED
|
@@ -507,6 +507,7 @@ useGraineEvents((e) => {
|
|
|
507
507
|
case "conversation_ended": analytics.track("agent_call_ended", { reason: e.reason }); break;
|
|
508
508
|
case "agent_volunteered": analytics.track("agent_spoke_first"); break;
|
|
509
509
|
case "action_requested": analytics.track("agent_action", { name: e.name }); break;
|
|
510
|
+
case "action_completed": analytics.track("agent_action_result", { name: e.name, status: e.status }); break;
|
|
510
511
|
}
|
|
511
512
|
});
|
|
512
513
|
```
|
|
@@ -765,6 +766,109 @@ and resamples in its output stage, which is where the artefacts came from.
|
|
|
765
766
|
|
|
766
767
|
---
|
|
767
768
|
|
|
769
|
+
## Friction detection
|
|
770
|
+
|
|
771
|
+
The agent speaks up when the customer looks stuck — before they say so. The
|
|
772
|
+
SDK reads what your screens already report and turns it into five signals:
|
|
773
|
+
|
|
774
|
+
| Signal | Fires when |
|
|
775
|
+
|---|---|
|
|
776
|
+
| `stalled_on_step` | untouched for `idle.afterMs` (a stuck field is named) |
|
|
777
|
+
| `repeated_field_error` | the same field shows an error `count` times in `windowMs` |
|
|
778
|
+
| `looping_between_screens` | back on the same screen `visits` times in `windowMs` |
|
|
779
|
+
| `rage_taps` | a `useGraineTap`-tracked tap `taps` times in `windowMs` |
|
|
780
|
+
| `screen_error` | the screen reports an `error` banner |
|
|
781
|
+
|
|
782
|
+
Each is sent to the agent as an `app_event` (it still decides whether to
|
|
783
|
+
speak, within `nudge.cooldownSeconds` / `nudge.maxPerSession`) and to your app
|
|
784
|
+
as `friction_detected` on `useGraineEvents`.
|
|
785
|
+
|
|
786
|
+
**Configure it in the dashboard** — Agent → Embed & Widgets → Friction — and
|
|
787
|
+
every install picks it up with the session. Or pass rules from the app for the
|
|
788
|
+
screens only it knows about; the app's layer wins:
|
|
789
|
+
|
|
790
|
+
```tsx
|
|
791
|
+
<GraineProvider client={client} friction={{
|
|
792
|
+
screens: {
|
|
793
|
+
terms: { ignore: true }, // reading is not stuck
|
|
794
|
+
otp: { idle: { afterMs: 20000 },
|
|
795
|
+
message: "The customer has been on the OTP screen {seconds}s without entering a code." },
|
|
796
|
+
home: { idle: false },
|
|
797
|
+
},
|
|
798
|
+
nudge: { openLauncher: true }, // your app decides what "open" means
|
|
799
|
+
}}>
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
Every rule accepts `false` to switch it off; `message` templates take
|
|
803
|
+
`{title} {screen} {field} {error} {count} {seconds} {tap}`. The full document
|
|
804
|
+
with defaults is `DEFAULT_FRICTION`.
|
|
805
|
+
|
|
806
|
+
```tsx
|
|
807
|
+
useGraineEvents((e) => {
|
|
808
|
+
if (e.type === "friction_detected" && client.friction.nudge.openLauncher) openAssistant();
|
|
809
|
+
});
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
## What Call History shows for an in-app conversation
|
|
813
|
+
|
|
814
|
+
Every conversation lands in the dashboard's Call History with, beside the
|
|
815
|
+
transcript:
|
|
816
|
+
|
|
817
|
+
- **What the agent did in the app** — every action it called, the arguments,
|
|
818
|
+
whether the app accepted or refused it, and the app's own answer
|
|
819
|
+
("Opened the privacy screen.", "I can reach: settings, favourites, …").
|
|
820
|
+
- **What happened in the app** — the screens the customer moved through, the
|
|
821
|
+
taps you track with `useGraineTap`, and every friction signal, with whether
|
|
822
|
+
the agent spoke to it and, if not, why (it was mid-sentence, it had just
|
|
823
|
+
spoken, it had reached its limit).
|
|
824
|
+
- **Who it was** — from `identify()`.
|
|
825
|
+
|
|
826
|
+
All of it exports to CSV. Nothing needs to be instrumented beyond what the
|
|
827
|
+
screens already report.
|
|
828
|
+
|
|
829
|
+
## Captions on WebRTC
|
|
830
|
+
|
|
831
|
+
Captions — the agent's line and what the customer is saying — reach the host
|
|
832
|
+
on both transports. On WebRTC the audio carries no text, so the runtime
|
|
833
|
+
mirrors the same caption frames over the control channel; a native bar using
|
|
834
|
+
`onCaption` sees `role: "customer"` and `role: "agent"` lines either way.
|
|
835
|
+
|
|
836
|
+
---
|
|
837
|
+
|
|
838
|
+
## Coming from RevRag
|
|
839
|
+
|
|
840
|
+
Same shape, different names. If you have a RevRag integration, this is the
|
|
841
|
+
whole translation:
|
|
842
|
+
|
|
843
|
+
| RevRag | Graine |
|
|
844
|
+
| --- | --- |
|
|
845
|
+
| `useInitialize({ apiKey })` → `{ isInitialized, error }` | `useGraineReady()` → `{ ready, error }` (the key goes on `GraineProvider`) |
|
|
846
|
+
| `EmbedProvider navigationRef appVersion` | `GraineProvider navigationRef appVersion` |
|
|
847
|
+
| `includeScreens` | `includeScreens` |
|
|
848
|
+
| `embedButtonDelayMs` | `launcherDelayMs` |
|
|
849
|
+
| `embedButtonVisibilityConfig` `{ defaultDelayMs, defaultInset, groups }` | `visibility` `{ defaultDelayMs, defaultInset, groups }` |
|
|
850
|
+
| `EmbedButtonGroupConfig` `{ id, screens, continuity, delayMs, delayPolicy, inset }` | `LauncherGroup` — identical fields |
|
|
851
|
+
| `continuous` / `perScreen` | same |
|
|
852
|
+
| `perScreen` / `oncePerGroupEntry` / `oncePerAppSession` | same |
|
|
853
|
+
| `EmbedButtonInset` | `LauncherInset` — same shape |
|
|
854
|
+
| `EmbedButton` (mount it yourself) | `GraineLauncher` |
|
|
855
|
+
| `Embed.Event(USER_DATA, { app_user_id, data })` | `useGraineIdentify()` / `client.identify({ id, name, … })` |
|
|
856
|
+
| `Embed.Event(SCREEN_STATE, { screen, data })` | `useGraineScreen({ screen, fields })` |
|
|
857
|
+
| `Embed.Event(CUSTOM_EVENT, data)` / `ANALYTICS_DATA` | `useGraineTrack()` / `client.track(name, data)` |
|
|
858
|
+
| `embedOnAgent(AGENT_CONVERSATION_STARTED / ENDED)` | `useGraineEvents(e => e.type === "conversation_started" / "conversation_ended")` |
|
|
859
|
+
| `AgentEvent.MICROPHONE_PERMISSION_DENIED` | `{ type: "mic_denied", reason }` on the same stream |
|
|
860
|
+
| `AgentEvent.POPUP_MESSAGE_VISIBLE` | `{ type: "launcher_shown", screen }` |
|
|
861
|
+
| `checkPermissions()` | `requestMicrophonePermission()` |
|
|
862
|
+
| server `widget_config` | `appearance` from the session, via `resolveNativeAppearance` |
|
|
863
|
+
| LiveKit native setup | none — see *Why there is no LiveKit step* |
|
|
864
|
+
| Friction detection (dashboard switch) | Per-screen JSON rules — `friction` on the provider, or Embed & Widgets → Friction; five named signals, `friction_detected` event |
|
|
865
|
+
|
|
866
|
+
Two things RevRag has that are deliberately absent: a LiveKit install step
|
|
867
|
+
(audio rides a WebView on a hosted page, so audio fixes ship without an app
|
|
868
|
+
release), and click tracking on every touchable by default (`useGraineTap` is
|
|
869
|
+
opt-in per element, because a stream of every tap is noise the agent has to be
|
|
870
|
+
told to ignore).
|
|
871
|
+
|
|
768
872
|
## Reference
|
|
769
873
|
|
|
770
874
|
| Hook | For |
|
package/dist/client.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext } from "./protocol.js";
|
|
2
|
+
import { type FrictionConfig } from "./friction.js";
|
|
2
3
|
export declare const SUBPROTOCOL = "graine.embed.v1";
|
|
3
4
|
export interface GraineInAppOptions {
|
|
4
5
|
baseUrl: string;
|
|
5
6
|
publishableKey: string;
|
|
6
7
|
variables?: Record<string, string>;
|
|
7
8
|
stallAfterMs?: number;
|
|
9
|
+
friction?: FrictionConfig;
|
|
8
10
|
voice?: boolean;
|
|
9
11
|
WebSocketImpl?: any;
|
|
10
12
|
fetchImpl?: typeof fetch;
|
|
@@ -22,6 +24,7 @@ export interface SessionConfig {
|
|
|
22
24
|
toolName?: string;
|
|
23
25
|
name?: string;
|
|
24
26
|
}>;
|
|
27
|
+
friction?: FrictionConfig;
|
|
25
28
|
}
|
|
26
29
|
export interface ActionDiagnosis {
|
|
27
30
|
usable: string[];
|
|
@@ -43,17 +46,24 @@ export declare class GraineInAppClient {
|
|
|
43
46
|
private contextTimer;
|
|
44
47
|
private pendingScreen;
|
|
45
48
|
private stallTimer;
|
|
46
|
-
private
|
|
49
|
+
private detector;
|
|
50
|
+
private serverFriction;
|
|
51
|
+
private appFriction;
|
|
52
|
+
private legacyStall;
|
|
47
53
|
private pingTimer;
|
|
48
54
|
private agentSpeaking;
|
|
49
55
|
private bargedIn;
|
|
50
56
|
private muted;
|
|
51
57
|
private playoutClock;
|
|
52
58
|
private identity;
|
|
59
|
+
private appVersion;
|
|
53
60
|
private recentEvents;
|
|
54
61
|
constructor(opts: GraineInAppOptions);
|
|
62
|
+
private effectiveFriction;
|
|
63
|
+
configureFriction(override: FrictionConfig | null | undefined): void;
|
|
64
|
+
get friction(): Required<FrictionConfig>;
|
|
55
65
|
on(event: string, fn: Listener): () => void;
|
|
56
|
-
|
|
66
|
+
emit(event: string, payload?: any): void;
|
|
57
67
|
private get fetch();
|
|
58
68
|
session(): Promise<SessionConfig>;
|
|
59
69
|
checkActions(): ActionDiagnosis;
|
|
@@ -66,6 +76,7 @@ export declare class GraineInAppClient {
|
|
|
66
76
|
executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
|
|
67
77
|
private runAction;
|
|
68
78
|
private invokeAction;
|
|
79
|
+
private performAction;
|
|
69
80
|
get isConnected(): boolean;
|
|
70
81
|
getScreen(): ScreenContext | null;
|
|
71
82
|
getIdentity(): Record<string, string>;
|
|
@@ -81,9 +92,11 @@ export declare class GraineInAppClient {
|
|
|
81
92
|
registerAction(name: string, handler: ActionHandler): () => void;
|
|
82
93
|
get availableActions(): string[];
|
|
83
94
|
setScreen(context: ScreenContext | null): void;
|
|
95
|
+
private dispatchFriction;
|
|
84
96
|
reportEvent(event: AppEvent): void;
|
|
85
97
|
track(name: string, data?: Record<string, unknown>): void;
|
|
86
98
|
private scheduleScreen;
|
|
99
|
+
setAppVersion(version: string | null | undefined): void;
|
|
87
100
|
buildContextFrame(): {
|
|
88
101
|
type: "app_context";
|
|
89
102
|
context: Record<string, unknown>;
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
|
|
2
2
|
import { maskDeep } from "./mask.js";
|
|
3
|
+
import { FrictionDetector, resolveFriction } from "./friction.js";
|
|
3
4
|
export const SUBPROTOCOL = "graine.embed.v1";
|
|
4
5
|
const PING_MS = 25000;
|
|
5
6
|
export class GraineInAppClient {
|
|
@@ -17,19 +18,38 @@ export class GraineInAppClient {
|
|
|
17
18
|
this.contextTimer = null;
|
|
18
19
|
this.pendingScreen = false;
|
|
19
20
|
this.stallTimer = null;
|
|
20
|
-
this.
|
|
21
|
+
this.serverFriction = null;
|
|
22
|
+
this.legacyStall = false;
|
|
21
23
|
this.pingTimer = null;
|
|
22
24
|
this.agentSpeaking = false;
|
|
23
25
|
this.bargedIn = false;
|
|
24
26
|
this.muted = false;
|
|
25
27
|
this.playoutClock = null;
|
|
26
28
|
this.identity = {};
|
|
29
|
+
this.appVersion = null;
|
|
27
30
|
this.recentEvents = [];
|
|
28
31
|
if (!opts?.publishableKey)
|
|
29
32
|
throw new Error("[Graine] publishableKey is required.");
|
|
30
33
|
if (!opts?.baseUrl)
|
|
31
34
|
throw new Error("[Graine] baseUrl is required.");
|
|
32
35
|
this.opts = { stallAfterMs: 45000, ...opts };
|
|
36
|
+
this.appFriction = opts.friction;
|
|
37
|
+
this.legacyStall = "stallAfterMs" in opts;
|
|
38
|
+
this.detector = new FrictionDetector(this.effectiveFriction());
|
|
39
|
+
}
|
|
40
|
+
effectiveFriction() {
|
|
41
|
+
const legacy = this.legacyStall
|
|
42
|
+
? { idle: this.opts.stallAfterMs && this.opts.stallAfterMs > 0 ? { afterMs: this.opts.stallAfterMs } : false }
|
|
43
|
+
: null;
|
|
44
|
+
return resolveFriction(this.serverFriction, legacy, this.appFriction);
|
|
45
|
+
}
|
|
46
|
+
configureFriction(override) {
|
|
47
|
+
this.appFriction = override ?? undefined;
|
|
48
|
+
this.detector.configure(this.effectiveFriction());
|
|
49
|
+
this.armStall();
|
|
50
|
+
}
|
|
51
|
+
get friction() {
|
|
52
|
+
return this.detector.settings;
|
|
33
53
|
}
|
|
34
54
|
on(event, fn) {
|
|
35
55
|
if (!this.listeners.has(event))
|
|
@@ -68,6 +88,9 @@ export class GraineInAppClient {
|
|
|
68
88
|
throw new Error(data?.error || `Embed rejected (${res.status})`);
|
|
69
89
|
}
|
|
70
90
|
this.config = data;
|
|
91
|
+
this.serverFriction = data && typeof data.friction === "object" ? data.friction : null;
|
|
92
|
+
this.detector.configure(this.effectiveFriction());
|
|
93
|
+
this.armStall();
|
|
71
94
|
this.reportActionMismatch();
|
|
72
95
|
return this.config;
|
|
73
96
|
}
|
|
@@ -262,6 +285,15 @@ export class GraineInAppClient {
|
|
|
262
285
|
this.send({ type: "app_action_result", action_id: request.action_id, ...result });
|
|
263
286
|
}
|
|
264
287
|
async invokeAction(request) {
|
|
288
|
+
const result = await this.performAction(request);
|
|
289
|
+
this.emit("action_result", {
|
|
290
|
+
name: request.name,
|
|
291
|
+
status: result.status,
|
|
292
|
+
...(result.message ? { message: result.message } : {}),
|
|
293
|
+
});
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
async performAction(request) {
|
|
265
297
|
const handler = this.actions.get(request.name);
|
|
266
298
|
if (!handler) {
|
|
267
299
|
return {
|
|
@@ -334,13 +366,25 @@ export class GraineInAppClient {
|
|
|
334
366
|
setScreen(context) {
|
|
335
367
|
const changedScreen = context?.screen !== this.screen?.screen;
|
|
336
368
|
this.screen = context;
|
|
369
|
+
const now = Date.now();
|
|
337
370
|
if (changedScreen) {
|
|
338
|
-
this.screenSince =
|
|
339
|
-
this.stallReportedFor = null;
|
|
340
|
-
this.armStall();
|
|
371
|
+
this.screenSince = now;
|
|
341
372
|
}
|
|
373
|
+
this.dispatchFriction(this.detector.onScreen(context, now));
|
|
374
|
+
this.armStall();
|
|
342
375
|
this.scheduleScreen();
|
|
343
376
|
}
|
|
377
|
+
dispatchFriction(signals) {
|
|
378
|
+
for (const s of signals) {
|
|
379
|
+
this.emit("friction", s);
|
|
380
|
+
this.reportEvent({
|
|
381
|
+
name: s.name,
|
|
382
|
+
detail: s.detail,
|
|
383
|
+
cooldown_seconds: s.cooldown_seconds,
|
|
384
|
+
max_per_session: s.max_per_session,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
344
388
|
reportEvent(event) {
|
|
345
389
|
this.emit("app_event", event);
|
|
346
390
|
this.send({ type: "app_event", event });
|
|
@@ -351,6 +395,7 @@ export class GraineInAppClient {
|
|
|
351
395
|
this.recentEvents.push({ name, at: Date.now(), ...(data ? { data } : {}) });
|
|
352
396
|
if (this.recentEvents.length > 10)
|
|
353
397
|
this.recentEvents.shift();
|
|
398
|
+
this.dispatchFriction(this.detector.onTrack(name, Date.now()));
|
|
354
399
|
if (this.screen)
|
|
355
400
|
this.scheduleScreen();
|
|
356
401
|
}
|
|
@@ -370,6 +415,10 @@ export class GraineInAppClient {
|
|
|
370
415
|
}
|
|
371
416
|
}, CONTEXT_THROTTLE_MS);
|
|
372
417
|
}
|
|
418
|
+
setAppVersion(version) {
|
|
419
|
+
const v = version == null ? "" : String(version).trim();
|
|
420
|
+
this.appVersion = v || null;
|
|
421
|
+
}
|
|
373
422
|
buildContextFrame() {
|
|
374
423
|
const hasIdentity = Object.keys(this.identity).length > 0;
|
|
375
424
|
if (!this.screen && !hasIdentity && this.recentEvents.length === 0)
|
|
@@ -381,6 +430,7 @@ export class GraineInAppClient {
|
|
|
381
430
|
...rest,
|
|
382
431
|
...(this.screen ? { idle_ms: Date.now() - this.screenSince } : {}),
|
|
383
432
|
...(Object.keys(this.identity).length ? { user: this.identity } : {}),
|
|
433
|
+
...(this.appVersion ? { app_version: this.appVersion } : {}),
|
|
384
434
|
...(this.recentEvents.length ? { recent_events: this.recentEvents } : {}),
|
|
385
435
|
...(this.highlightables.size ? { highlightable: [...this.highlightables] } : {}),
|
|
386
436
|
}),
|
|
@@ -396,24 +446,14 @@ export class GraineInAppClient {
|
|
|
396
446
|
}
|
|
397
447
|
armStall() {
|
|
398
448
|
clearTimeout(this.stallTimer);
|
|
399
|
-
const
|
|
400
|
-
if (
|
|
449
|
+
const due = this.detector.idleDueIn(Date.now());
|
|
450
|
+
if (due === null)
|
|
401
451
|
return;
|
|
402
|
-
const screenId = this.screen.screen;
|
|
403
452
|
this.stallTimer = setTimeout(() => {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const stuck = (this.screen?.fields ?? []).find((f) => f.status === "invalid" || f.error || f.status === "empty");
|
|
409
|
-
this.reportEvent({
|
|
410
|
-
name: "stalled_on_step",
|
|
411
|
-
detail: stuck
|
|
412
|
-
? `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s. ` +
|
|
413
|
-
`The "${stuck.label ?? stuck.name}" field is ${stuck.error ? `showing: ${stuck.error}` : stuck.status}.`
|
|
414
|
-
: `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s.`,
|
|
415
|
-
});
|
|
416
|
-
}, after);
|
|
453
|
+
const signal = this.detector.onIdle(Date.now());
|
|
454
|
+
if (signal)
|
|
455
|
+
this.dispatchFriction([signal]);
|
|
456
|
+
}, due);
|
|
417
457
|
}
|
|
418
458
|
identify(user) {
|
|
419
459
|
if (!user) {
|
|
@@ -430,7 +470,9 @@ export class GraineInAppClient {
|
|
|
430
470
|
this.scheduleScreen();
|
|
431
471
|
}
|
|
432
472
|
noteInteraction() {
|
|
433
|
-
|
|
473
|
+
const now = Date.now();
|
|
474
|
+
this.screenSince = now;
|
|
475
|
+
this.detector.onInteraction(now);
|
|
434
476
|
this.armStall();
|
|
435
477
|
}
|
|
436
478
|
say(text) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ScreenContext } from "./protocol.js";
|
|
2
|
+
export interface IdleRule {
|
|
3
|
+
afterMs: number;
|
|
4
|
+
}
|
|
5
|
+
export interface RepeatedErrorsRule {
|
|
6
|
+
count: number;
|
|
7
|
+
windowMs: number;
|
|
8
|
+
}
|
|
9
|
+
export interface BackAndForthRule {
|
|
10
|
+
visits: number;
|
|
11
|
+
windowMs: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RageTapsRule {
|
|
14
|
+
taps: number;
|
|
15
|
+
windowMs: number;
|
|
16
|
+
}
|
|
17
|
+
export interface FrictionScreenRules {
|
|
18
|
+
ignore?: boolean;
|
|
19
|
+
idle?: IdleRule | false;
|
|
20
|
+
repeatedErrors?: RepeatedErrorsRule | false;
|
|
21
|
+
backAndForth?: BackAndForthRule | false;
|
|
22
|
+
rageTaps?: RageTapsRule | false;
|
|
23
|
+
screenError?: boolean;
|
|
24
|
+
message?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface FrictionNudge {
|
|
27
|
+
cooldownSeconds?: number;
|
|
28
|
+
maxPerSession?: number;
|
|
29
|
+
openLauncher?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface FrictionConfig extends FrictionScreenRules {
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
cooldownMs?: number;
|
|
34
|
+
nudge?: FrictionNudge;
|
|
35
|
+
screens?: Record<string, FrictionScreenRules>;
|
|
36
|
+
}
|
|
37
|
+
export declare const DEFAULT_FRICTION: Readonly<Required<Omit<FrictionConfig, "message" | "ignore">>>;
|
|
38
|
+
export declare function resolveFriction(...layers: Array<FrictionConfig | null | undefined>): Required<FrictionConfig>;
|
|
39
|
+
export declare function rulesFor(config: Required<FrictionConfig>, screenId: string | null | undefined): Required<FrictionScreenRules>;
|
|
40
|
+
export type FrictionKind = "stalled_on_step" | "repeated_field_error" | "looping_between_screens" | "rage_taps" | "screen_error";
|
|
41
|
+
export interface FrictionSignal {
|
|
42
|
+
name: FrictionKind;
|
|
43
|
+
screen: string;
|
|
44
|
+
detail: string;
|
|
45
|
+
data: Record<string, unknown>;
|
|
46
|
+
cooldown_seconds: number;
|
|
47
|
+
max_per_session: number;
|
|
48
|
+
}
|
|
49
|
+
export declare class FrictionDetector {
|
|
50
|
+
private config;
|
|
51
|
+
private screen;
|
|
52
|
+
private screenSince;
|
|
53
|
+
private lastSignalAt;
|
|
54
|
+
private idleFiredFor;
|
|
55
|
+
private visits;
|
|
56
|
+
private errorHits;
|
|
57
|
+
private errorFiredFor;
|
|
58
|
+
private taps;
|
|
59
|
+
private screenErrorFiredFor;
|
|
60
|
+
constructor(config?: FrictionConfig | Required<FrictionConfig>);
|
|
61
|
+
configure(config: Required<FrictionConfig>): void;
|
|
62
|
+
get settings(): Required<FrictionConfig>;
|
|
63
|
+
private signal;
|
|
64
|
+
private active;
|
|
65
|
+
onScreen(ctx: ScreenContext | null, now: number): FrictionSignal[];
|
|
66
|
+
onInteraction(now: number): void;
|
|
67
|
+
onTrack(name: string, now: number): FrictionSignal[];
|
|
68
|
+
idleDueIn(now: number): number | null;
|
|
69
|
+
onIdle(now: number): FrictionSignal | null;
|
|
70
|
+
}
|
package/dist/friction.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
const DEFAULT_RULES = {
|
|
2
|
+
idle: { afterMs: 45000 },
|
|
3
|
+
repeatedErrors: { count: 2, windowMs: 60000 },
|
|
4
|
+
backAndForth: { visits: 3, windowMs: 45000 },
|
|
5
|
+
rageTaps: { taps: 4, windowMs: 1500 },
|
|
6
|
+
};
|
|
7
|
+
export const DEFAULT_FRICTION = Object.freeze({
|
|
8
|
+
enabled: true,
|
|
9
|
+
cooldownMs: 60000,
|
|
10
|
+
...DEFAULT_RULES,
|
|
11
|
+
screenError: true,
|
|
12
|
+
nudge: { cooldownSeconds: 20, maxPerSession: 3, openLauncher: false },
|
|
13
|
+
screens: {},
|
|
14
|
+
});
|
|
15
|
+
function num(v) {
|
|
16
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
17
|
+
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : null;
|
|
18
|
+
}
|
|
19
|
+
function rule(raw, shape) {
|
|
20
|
+
if (raw === false)
|
|
21
|
+
return false;
|
|
22
|
+
if (raw === undefined || raw === null)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (typeof raw !== "object")
|
|
25
|
+
return undefined;
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [k, fallback] of Object.entries(shape)) {
|
|
28
|
+
const v = num(raw[k]);
|
|
29
|
+
out[k] = v ?? fallback;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function screenRules(raw, base) {
|
|
34
|
+
if (!raw || typeof raw !== "object")
|
|
35
|
+
return {};
|
|
36
|
+
const r = raw;
|
|
37
|
+
const out = {};
|
|
38
|
+
if (r.ignore === true)
|
|
39
|
+
out.ignore = true;
|
|
40
|
+
const idle = rule(r.idle, base.idle || DEFAULT_RULES.idle);
|
|
41
|
+
if (idle !== undefined)
|
|
42
|
+
out.idle = idle;
|
|
43
|
+
const rep = rule(r.repeatedErrors, base.repeatedErrors || DEFAULT_RULES.repeatedErrors);
|
|
44
|
+
if (rep !== undefined)
|
|
45
|
+
out.repeatedErrors = rep;
|
|
46
|
+
const bf = rule(r.backAndForth, base.backAndForth || DEFAULT_RULES.backAndForth);
|
|
47
|
+
if (bf !== undefined)
|
|
48
|
+
out.backAndForth = bf;
|
|
49
|
+
const rage = rule(r.rageTaps, base.rageTaps || DEFAULT_RULES.rageTaps);
|
|
50
|
+
if (rage !== undefined)
|
|
51
|
+
out.rageTaps = rage;
|
|
52
|
+
if (typeof r.screenError === "boolean")
|
|
53
|
+
out.screenError = r.screenError;
|
|
54
|
+
if (typeof r.message === "string" && r.message.trim())
|
|
55
|
+
out.message = r.message.trim();
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
export function resolveFriction(...layers) {
|
|
59
|
+
let out = {
|
|
60
|
+
...DEFAULT_FRICTION,
|
|
61
|
+
nudge: { ...DEFAULT_FRICTION.nudge },
|
|
62
|
+
screens: {},
|
|
63
|
+
ignore: false,
|
|
64
|
+
message: "",
|
|
65
|
+
};
|
|
66
|
+
for (const layer of layers) {
|
|
67
|
+
if (!layer || typeof layer !== "object")
|
|
68
|
+
continue;
|
|
69
|
+
const top = screenRules(layer, out);
|
|
70
|
+
out = { ...out, ...top };
|
|
71
|
+
if (typeof layer.enabled === "boolean")
|
|
72
|
+
out.enabled = layer.enabled;
|
|
73
|
+
const cd = num(layer.cooldownMs);
|
|
74
|
+
if (cd !== null)
|
|
75
|
+
out.cooldownMs = cd;
|
|
76
|
+
if (layer.nudge && typeof layer.nudge === "object") {
|
|
77
|
+
const n = layer.nudge;
|
|
78
|
+
out.nudge = {
|
|
79
|
+
...out.nudge,
|
|
80
|
+
...(num(n.cooldownSeconds) !== null ? { cooldownSeconds: num(n.cooldownSeconds) } : {}),
|
|
81
|
+
...(num(n.maxPerSession) !== null ? { maxPerSession: num(n.maxPerSession) } : {}),
|
|
82
|
+
...(typeof n.openLauncher === "boolean" ? { openLauncher: n.openLauncher } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (layer.screens && typeof layer.screens === "object") {
|
|
86
|
+
const screens = { ...out.screens };
|
|
87
|
+
for (const [id, raw] of Object.entries(layer.screens)) {
|
|
88
|
+
if (!id)
|
|
89
|
+
continue;
|
|
90
|
+
screens[id] = { ...(screens[id] || {}), ...screenRules(raw, out) };
|
|
91
|
+
}
|
|
92
|
+
out.screens = screens;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
export function rulesFor(config, screenId) {
|
|
98
|
+
const over = (screenId && config.screens[screenId]) || {};
|
|
99
|
+
return {
|
|
100
|
+
ignore: over.ignore ?? false,
|
|
101
|
+
idle: over.idle ?? config.idle,
|
|
102
|
+
repeatedErrors: over.repeatedErrors ?? config.repeatedErrors,
|
|
103
|
+
backAndForth: over.backAndForth ?? config.backAndForth,
|
|
104
|
+
rageTaps: over.rageTaps ?? config.rageTaps,
|
|
105
|
+
screenError: over.screenError ?? config.screenError,
|
|
106
|
+
message: over.message ?? config.message ?? "",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function fill(template, vars) {
|
|
110
|
+
return template.replace(/\{(\w+)\}/g, (_, k) => (vars[k] === undefined || vars[k] === null ? "" : String(vars[k])));
|
|
111
|
+
}
|
|
112
|
+
const stuckField = (fields) => (fields ?? []).find((f) => f.status === "invalid" || f.error) ?? (fields ?? []).find((f) => f.status === "empty");
|
|
113
|
+
export class FrictionDetector {
|
|
114
|
+
constructor(config) {
|
|
115
|
+
this.screen = null;
|
|
116
|
+
this.screenSince = 0;
|
|
117
|
+
this.lastSignalAt = new Map();
|
|
118
|
+
this.idleFiredFor = null;
|
|
119
|
+
this.visits = [];
|
|
120
|
+
this.errorHits = new Map();
|
|
121
|
+
this.errorFiredFor = new Set();
|
|
122
|
+
this.taps = new Map();
|
|
123
|
+
this.screenErrorFiredFor = null;
|
|
124
|
+
this.config = resolveFriction(config);
|
|
125
|
+
}
|
|
126
|
+
configure(config) {
|
|
127
|
+
this.config = config;
|
|
128
|
+
}
|
|
129
|
+
get settings() {
|
|
130
|
+
return this.config;
|
|
131
|
+
}
|
|
132
|
+
signal(name, vars, fallback, now) {
|
|
133
|
+
const screen = this.screen?.screen ?? "";
|
|
134
|
+
const last = this.lastSignalAt.get(screen) ?? -Infinity;
|
|
135
|
+
if (now - last < this.config.cooldownMs)
|
|
136
|
+
return null;
|
|
137
|
+
this.lastSignalAt.set(screen, now);
|
|
138
|
+
const rules = rulesFor(this.config, screen);
|
|
139
|
+
const all = { title: this.screen?.title ?? screen, screen, ...vars };
|
|
140
|
+
return {
|
|
141
|
+
name,
|
|
142
|
+
screen,
|
|
143
|
+
detail: rules.message ? fill(rules.message, all) : fill(fallback, all),
|
|
144
|
+
data: vars,
|
|
145
|
+
cooldown_seconds: this.config.nudge.cooldownSeconds ?? 20,
|
|
146
|
+
max_per_session: this.config.nudge.maxPerSession ?? 3,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
active() {
|
|
150
|
+
if (!this.config.enabled || !this.screen)
|
|
151
|
+
return null;
|
|
152
|
+
const r = rulesFor(this.config, this.screen.screen);
|
|
153
|
+
return r.ignore ? null : r;
|
|
154
|
+
}
|
|
155
|
+
onScreen(ctx, now) {
|
|
156
|
+
const prev = this.screen;
|
|
157
|
+
this.screen = ctx;
|
|
158
|
+
if (!ctx)
|
|
159
|
+
return [];
|
|
160
|
+
const changed = !prev || prev.screen !== ctx.screen;
|
|
161
|
+
if (changed) {
|
|
162
|
+
this.screenSince = now;
|
|
163
|
+
this.idleFiredFor = null;
|
|
164
|
+
this.screenErrorFiredFor = null;
|
|
165
|
+
this.errorFiredFor.clear();
|
|
166
|
+
this.visits.push({ screen: ctx.screen, at: now });
|
|
167
|
+
if (this.visits.length > 50)
|
|
168
|
+
this.visits.shift();
|
|
169
|
+
}
|
|
170
|
+
const rules = this.active();
|
|
171
|
+
if (!rules)
|
|
172
|
+
return [];
|
|
173
|
+
const out = [];
|
|
174
|
+
if (changed && rules.backAndForth) {
|
|
175
|
+
const { visits, windowMs } = rules.backAndForth;
|
|
176
|
+
const recent = this.visits.filter((v) => v.screen === ctx.screen && now - v.at <= windowMs).length;
|
|
177
|
+
if (recent >= visits) {
|
|
178
|
+
const s = this.signal("looping_between_screens", { count: recent, seconds: Math.round(windowMs / 1000) }, `The customer has come back to the "{title}" screen {count} times in the last {seconds}s without getting past it.`, now);
|
|
179
|
+
if (s)
|
|
180
|
+
out.push(s);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (rules.repeatedErrors) {
|
|
184
|
+
const { count, windowMs } = rules.repeatedErrors;
|
|
185
|
+
for (const f of ctx.fields ?? []) {
|
|
186
|
+
if (!(f.status === "invalid" || f.error))
|
|
187
|
+
continue;
|
|
188
|
+
const key = `${ctx.screen}:${f.name}`;
|
|
189
|
+
const hits = (this.errorHits.get(key) ?? []).filter((t) => now - t <= windowMs);
|
|
190
|
+
const prevField = (prev?.fields ?? []).find((p) => p.name === f.name);
|
|
191
|
+
const same = prevField && !changed && (prevField.error ?? "") === (f.error ?? "") && prevField.status === f.status;
|
|
192
|
+
if (!same)
|
|
193
|
+
hits.push(now);
|
|
194
|
+
this.errorHits.set(key, hits);
|
|
195
|
+
if (hits.length >= count && !this.errorFiredFor.has(key)) {
|
|
196
|
+
this.errorFiredFor.add(key);
|
|
197
|
+
const s = this.signal("repeated_field_error", { field: f.label ?? f.name, error: f.error ?? f.status, count: hits.length }, `The "{field}" field on the "{title}" screen has rejected the customer {count} times — it is showing: {error}.`, now);
|
|
198
|
+
if (s)
|
|
199
|
+
out.push(s);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (rules.screenError && ctx.error && this.screenErrorFiredFor !== ctx.screen) {
|
|
204
|
+
this.screenErrorFiredFor = ctx.screen;
|
|
205
|
+
const s = this.signal("screen_error", { error: ctx.error }, `The "{title}" screen is showing an error: {error}.`, now);
|
|
206
|
+
if (s)
|
|
207
|
+
out.push(s);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
onInteraction(now) {
|
|
212
|
+
this.screenSince = now;
|
|
213
|
+
}
|
|
214
|
+
onTrack(name, now) {
|
|
215
|
+
const rules = this.active();
|
|
216
|
+
if (!rules || !rules.rageTaps || !this.screen)
|
|
217
|
+
return [];
|
|
218
|
+
if (!name.startsWith("tap:"))
|
|
219
|
+
return [];
|
|
220
|
+
const { taps, windowMs } = rules.rageTaps;
|
|
221
|
+
const key = `${this.screen.screen}:${name}`;
|
|
222
|
+
const times = (this.taps.get(key) ?? []).filter((t) => now - t <= windowMs);
|
|
223
|
+
times.push(now);
|
|
224
|
+
this.taps.set(key, times);
|
|
225
|
+
if (times.length < taps)
|
|
226
|
+
return [];
|
|
227
|
+
this.taps.set(key, []);
|
|
228
|
+
const s = this.signal("rage_taps", { tap: name.slice(4), count: times.length, seconds: (windowMs / 1000).toFixed(1) }, `The customer tapped "{tap}" {count} times in {seconds}s on the "{title}" screen — it may not be responding or not doing what they expect.`, now);
|
|
229
|
+
return s ? [s] : [];
|
|
230
|
+
}
|
|
231
|
+
idleDueIn(now) {
|
|
232
|
+
const rules = this.active();
|
|
233
|
+
if (!rules || !rules.idle || !this.screen)
|
|
234
|
+
return null;
|
|
235
|
+
if (this.idleFiredFor === this.screen.screen)
|
|
236
|
+
return null;
|
|
237
|
+
return Math.max(0, rules.idle.afterMs - (now - this.screenSince));
|
|
238
|
+
}
|
|
239
|
+
onIdle(now) {
|
|
240
|
+
const rules = this.active();
|
|
241
|
+
if (!rules || !rules.idle || !this.screen)
|
|
242
|
+
return null;
|
|
243
|
+
if (this.idleFiredFor === this.screen.screen)
|
|
244
|
+
return null;
|
|
245
|
+
if (now - this.screenSince < rules.idle.afterMs)
|
|
246
|
+
return null;
|
|
247
|
+
this.idleFiredFor = this.screen.screen;
|
|
248
|
+
const stuck = stuckField(this.screen.fields);
|
|
249
|
+
const seconds = Math.round(rules.idle.afterMs / 1000);
|
|
250
|
+
return this.signal("stalled_on_step", {
|
|
251
|
+
seconds,
|
|
252
|
+
field: stuck ? stuck.label ?? stuck.name : "",
|
|
253
|
+
error: stuck?.error ?? stuck?.status ?? "",
|
|
254
|
+
}, stuck
|
|
255
|
+
? `The customer has not touched the "{title}" screen for {seconds}s. The "{field}" field is ${stuck.error ? "showing: {error}" : "{error}"}.`
|
|
256
|
+
: `The customer has not touched the "{title}" screen for {seconds}s.`, now);
|
|
257
|
+
}
|
|
258
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol.js";
|
|
2
2
|
export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
|
|
3
|
+
export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type FrictionConfig, type FrictionScreenRules, type FrictionNudge, type FrictionSignal, type FrictionKind, } from "./friction.js";
|
|
3
4
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
|
|
4
5
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
|
|
5
6
|
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
6
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
|
|
7
|
+
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
|
|
8
|
+
export { requestMicrophonePermission } from "./react-native/permissions.js";
|
|
7
9
|
export { resolveNativeAppearance, type NativeAppearance, type NativeAppearanceFallback, } from "./react-native/appearance.js";
|
|
8
10
|
export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
|
|
9
11
|
export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { GraineInAppClient, } from "./client.js";
|
|
2
|
+
export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
|
|
2
3
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
|
|
3
4
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
|
|
4
5
|
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
5
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, } from "./react-native/index.js";
|
|
6
|
+
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, } from "./react-native/index.js";
|
|
7
|
+
export { requestMicrophonePermission } from "./react-native/permissions.js";
|
|
6
8
|
export { resolveNativeAppearance, } from "./react-native/appearance.js";
|
|
7
9
|
export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
|
|
8
10
|
export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
|
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { GraineInAppClient, type GraineInAppOptions } from "../client.js";
|
|
3
3
|
import { type AudioAdapter, type VoiceSessionOptions } from "../voice.js";
|
|
4
4
|
import type { ActionHandler, ScreenContext } from "../protocol.js";
|
|
5
|
+
import type { FrictionConfig } from "../friction.js";
|
|
5
6
|
import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
|
|
6
7
|
interface GraineContextValue {
|
|
7
8
|
client: GraineInAppClient;
|
|
@@ -21,6 +22,7 @@ interface GraineContextValue {
|
|
|
21
22
|
launcherInset: LauncherInset;
|
|
22
23
|
currentScreen: string | null;
|
|
23
24
|
appearance: Record<string, any> | null;
|
|
25
|
+
sessionReady: boolean;
|
|
24
26
|
onEvent: (fn: (e: GraineEvent) => void) => () => void;
|
|
25
27
|
track: (name: string, data?: Record<string, unknown>) => void;
|
|
26
28
|
identify: (user: Record<string, unknown> | null) => void;
|
|
@@ -43,12 +45,24 @@ export type GraineEvent = {
|
|
|
43
45
|
type: "action_completed";
|
|
44
46
|
name: string;
|
|
45
47
|
status: string;
|
|
48
|
+
message?: string;
|
|
46
49
|
} | {
|
|
47
50
|
type: "muted";
|
|
48
51
|
muted: boolean;
|
|
49
52
|
} | {
|
|
50
53
|
type: "error";
|
|
51
54
|
message: string;
|
|
55
|
+
} | {
|
|
56
|
+
type: "mic_denied";
|
|
57
|
+
reason: string;
|
|
58
|
+
} | {
|
|
59
|
+
type: "launcher_shown";
|
|
60
|
+
screen: string | null;
|
|
61
|
+
} | {
|
|
62
|
+
type: "friction_detected";
|
|
63
|
+
name: string;
|
|
64
|
+
screen: string;
|
|
65
|
+
detail: string;
|
|
52
66
|
};
|
|
53
67
|
export interface GraineWidget {
|
|
54
68
|
id: string;
|
|
@@ -75,11 +89,13 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
|
|
|
75
89
|
navigationRef?: {
|
|
76
90
|
current: any;
|
|
77
91
|
} | null;
|
|
92
|
+
appVersion?: string;
|
|
93
|
+
friction?: FrictionConfig;
|
|
78
94
|
includeScreens?: string[];
|
|
79
95
|
launcherDelayMs?: number;
|
|
80
96
|
visibility?: LauncherVisibility;
|
|
81
97
|
}
|
|
82
|
-
export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
|
|
98
|
+
export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
|
|
83
99
|
export declare function useGraineAgent(): GraineContextValue;
|
|
84
100
|
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
85
101
|
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
@@ -101,4 +117,8 @@ export declare function useGraineWidget(): {
|
|
|
101
117
|
dismiss: () => void;
|
|
102
118
|
};
|
|
103
119
|
export declare function useGraineAction(name: string, handler: ActionHandler): void;
|
|
120
|
+
export declare function useGraineReady(): {
|
|
121
|
+
ready: boolean;
|
|
122
|
+
error: string | null;
|
|
123
|
+
};
|
|
104
124
|
export {};
|
|
@@ -5,7 +5,7 @@ import { GraineInAppClient } from "../client.js";
|
|
|
5
5
|
import { VoiceSession } from "../voice.js";
|
|
6
6
|
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
|
|
7
7
|
const Ctx = createContext(null);
|
|
8
|
-
export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
|
|
8
|
+
export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
|
|
9
9
|
const clientRef = useRef(null);
|
|
10
10
|
if (!clientRef.current) {
|
|
11
11
|
clientRef.current =
|
|
@@ -37,6 +37,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
37
37
|
return () => { listenersRef.current.delete(fn); };
|
|
38
38
|
}, []);
|
|
39
39
|
const [appearance, setAppearance] = useState(null);
|
|
40
|
+
const [sessionReady, setSessionReady] = useState(false);
|
|
40
41
|
const [currentScreen, setCurrentScreen] = useState(null);
|
|
41
42
|
const [launcherVisible, setLauncherVisible] = useState(false);
|
|
42
43
|
const [launcherInset, setLauncherInset] = useState({ right: 16, bottom: 20 });
|
|
@@ -76,12 +77,14 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
76
77
|
}
|
|
77
78
|
if (decision.delayMs <= 0) {
|
|
78
79
|
setLauncherVisible(true);
|
|
80
|
+
emitEvent({ type: "launcher_shown", screen: route });
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
81
83
|
setLauncherVisible(false);
|
|
82
84
|
showTimerRef.current = setTimeout(() => {
|
|
83
85
|
showTimerRef.current = null;
|
|
84
86
|
setLauncherVisible(true);
|
|
87
|
+
emitEvent({ type: "launcher_shown", screen: route });
|
|
85
88
|
}, decision.delayMs);
|
|
86
89
|
};
|
|
87
90
|
apply();
|
|
@@ -95,6 +98,9 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
95
98
|
nav.removeListener("state", apply);
|
|
96
99
|
};
|
|
97
100
|
}, [navigationRef, client]);
|
|
101
|
+
useEffect(() => { client.setAppVersion(appVersion); }, [client, appVersion]);
|
|
102
|
+
useEffect(() => { if (friction !== undefined)
|
|
103
|
+
client.configureFriction(friction); }, [client, friction]);
|
|
98
104
|
useEffect(() => {
|
|
99
105
|
if (!navigationRef?.current)
|
|
100
106
|
setLauncherVisible(true);
|
|
@@ -129,6 +135,8 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
129
135
|
return [...prev, { role: "agent", text, live: true }];
|
|
130
136
|
})),
|
|
131
137
|
client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
|
|
138
|
+
client.on("mic_denied", (p) => emitEvent({ type: "mic_denied", reason: String(p?.reason || "NotAllowedError") })),
|
|
139
|
+
client.on("friction", (s) => emitEvent({ type: "friction_detected", name: String(s?.name ?? ""), screen: String(s?.screen ?? ""), detail: String(s?.detail ?? "") })),
|
|
132
140
|
client.on("muted", (m) => {
|
|
133
141
|
setMutedState(m);
|
|
134
142
|
emitEvent({ type: "muted", muted: m });
|
|
@@ -140,6 +148,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
140
148
|
emitEvent({ type: "agent_speaking", speaking });
|
|
141
149
|
}),
|
|
142
150
|
client.on("action", (req) => emitEvent({ type: "action_requested", name: req?.name ?? "" })),
|
|
151
|
+
client.on("action_result", (r) => emitEvent({
|
|
152
|
+
type: "action_completed",
|
|
153
|
+
name: r?.name ?? "",
|
|
154
|
+
status: r?.status ?? "unknown",
|
|
155
|
+
...(r?.message ? { message: String(r.message) } : {}),
|
|
156
|
+
})),
|
|
143
157
|
client.on("transcript", ({ text, final }) => {
|
|
144
158
|
if (text)
|
|
145
159
|
setCaption({ role: "customer", text, live: !final });
|
|
@@ -153,6 +167,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
153
167
|
const cfg = client.config ?? (await client.session());
|
|
154
168
|
if (cfg?.appearance)
|
|
155
169
|
setAppearance(cfg.appearance);
|
|
170
|
+
setSessionReady(true);
|
|
156
171
|
}
|
|
157
172
|
catch {
|
|
158
173
|
}
|
|
@@ -234,11 +249,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
234
249
|
launcherInset,
|
|
235
250
|
currentScreen,
|
|
236
251
|
appearance,
|
|
252
|
+
sessionReady,
|
|
237
253
|
onEvent,
|
|
238
254
|
track: (name, data) => client.track(name, data),
|
|
239
255
|
identify: (user) => client.identify(user),
|
|
240
256
|
}), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
|
|
241
|
-
launcherVisible, launcherInset, currentScreen, onEvent, appearance]);
|
|
257
|
+
launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady]);
|
|
242
258
|
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
243
259
|
}
|
|
244
260
|
function useGraine() {
|
|
@@ -368,3 +384,7 @@ export function useGraineAction(name, handler) {
|
|
|
368
384
|
handlerRef.current = handler;
|
|
369
385
|
useEffect(() => client.registerAction(name, (args) => handlerRef.current(args)), [client, name]);
|
|
370
386
|
}
|
|
387
|
+
export function useGraineReady() {
|
|
388
|
+
const { sessionReady, error } = useGraineAgent();
|
|
389
|
+
return { ready: sessionReady, error };
|
|
390
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PermissionsAndroid, Platform } from "react-native";
|
|
2
|
+
export async function requestMicrophonePermission(rationale = {}) {
|
|
3
|
+
if (Platform.OS !== "android")
|
|
4
|
+
return true;
|
|
5
|
+
try {
|
|
6
|
+
const perm = PermissionsAndroid?.PERMISSIONS?.RECORD_AUDIO;
|
|
7
|
+
if (!perm)
|
|
8
|
+
return true;
|
|
9
|
+
if (await PermissionsAndroid.check(perm))
|
|
10
|
+
return true;
|
|
11
|
+
const res = await PermissionsAndroid.request(perm, {
|
|
12
|
+
title: rationale.title ?? "Microphone",
|
|
13
|
+
message: rationale.message ?? "Allow the microphone so you can talk to the assistant.",
|
|
14
|
+
buttonPositive: rationale.buttonPositive ?? "Allow",
|
|
15
|
+
});
|
|
16
|
+
return res === PermissionsAndroid.RESULTS.GRANTED;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
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 { AppState,
|
|
3
|
+
import { AppState, View } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index.js";
|
|
5
|
+
import { requestMicrophonePermission } from "./permissions.js";
|
|
5
6
|
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }) {
|
|
6
7
|
const { client, appearance } = useGraineAgent();
|
|
7
8
|
const ref = useRef(null);
|
|
@@ -34,26 +35,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
|
|
|
34
35
|
})();
|
|
35
36
|
return () => { cancelled = true; };
|
|
36
37
|
}, [client, voiceAgentId]);
|
|
37
|
-
const ensureMic = useCallback(
|
|
38
|
-
if (Platform.OS !== "android")
|
|
39
|
-
return true;
|
|
40
|
-
try {
|
|
41
|
-
const perm = PermissionsAndroid?.PERMISSIONS?.RECORD_AUDIO;
|
|
42
|
-
if (!perm)
|
|
43
|
-
return true;
|
|
44
|
-
if (await PermissionsAndroid.check(perm))
|
|
45
|
-
return true;
|
|
46
|
-
const res = await PermissionsAndroid.request(perm, {
|
|
47
|
-
title: "Microphone",
|
|
48
|
-
message: "Allow the microphone so you can talk to the assistant.",
|
|
49
|
-
buttonPositive: "Allow",
|
|
50
|
-
});
|
|
51
|
-
return res === PermissionsAndroid.RESULTS.GRANTED;
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
}, []);
|
|
38
|
+
const ensureMic = useCallback(() => requestMicrophonePermission(), []);
|
|
57
39
|
const pushContext = useCallback(() => {
|
|
58
40
|
const frame = client.buildContextFrame();
|
|
59
41
|
if (!frame)
|
|
@@ -80,6 +62,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
|
|
|
80
62
|
if (!(await ensureMic())) {
|
|
81
63
|
setConnecting(false);
|
|
82
64
|
onMicDenied?.("NotAllowedError");
|
|
65
|
+
client.emit("mic_denied", { reason: "NotAllowedError" });
|
|
83
66
|
return;
|
|
84
67
|
}
|
|
85
68
|
post({ type: "graine:start-call" });
|
|
@@ -155,6 +138,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
|
|
|
155
138
|
onEnded?.();
|
|
156
139
|
return;
|
|
157
140
|
case "graine:mic-denied":
|
|
141
|
+
client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
|
|
158
142
|
onMicDenied?.(String(msg.reason || "denied"));
|
|
159
143
|
return;
|
|
160
144
|
case "graine:widget": {
|
package/package.json
CHANGED