@graineai/inapp-react-native 0.23.0 → 0.25.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 +107 -1
- package/README.md +14 -1
- package/dist/client.d.ts +13 -1
- package/dist/client.js +64 -21
- package/dist/friction.d.ts +70 -0
- package/dist/friction.js +258 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/react-native/index.d.ts +9 -1
- package/dist/react-native/index.js +11 -2
- 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,95 @@ 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
|
+
## Fast navigation: the agent always has the latest screen
|
|
813
|
+
|
|
814
|
+
A customer who taps through four screens and then asks "where am I?" gets the
|
|
815
|
+
fourth. The guarantees, so you do not have to think about them:
|
|
816
|
+
|
|
817
|
+
- **Immediate.** `useGraineScreen` reports a screen the moment it mounts, and
|
|
818
|
+
the voice launcher relays it to the runtime at once — the 400ms coalescing
|
|
819
|
+
applies only to the SDK's own text socket, never to a host's channel.
|
|
820
|
+
- **Ordered.** Every context frame carries a monotonic `seq`; the runtime keeps
|
|
821
|
+
the newest and drops anything that arrives late. The last screen always wins.
|
|
822
|
+
- **Cleanup-safe.** A screen's unmount clears the screen only if it is still
|
|
823
|
+
the one being described (`client.clearScreen(id)`), so a navigation library
|
|
824
|
+
unmounting the old screen after the new one mounted cannot erase it.
|
|
825
|
+
- **Reconnect-safe.** The newest context is replayed whenever the media or the
|
|
826
|
+
screen channel (re)connects.
|
|
827
|
+
|
|
828
|
+
If you drive the client yourself instead of using `useGraineScreen`, call
|
|
829
|
+
`client.setScreen(ctx)` on mount and `client.clearScreen(ctx.screen)` on
|
|
830
|
+
unmount — never `setScreen(null)` from a cleanup.
|
|
831
|
+
|
|
832
|
+
## What Call History shows for an in-app conversation
|
|
833
|
+
|
|
834
|
+
Every conversation lands in the dashboard's Call History with, beside the
|
|
835
|
+
transcript:
|
|
836
|
+
|
|
837
|
+
- **What the agent did in the app** — every action it called, the arguments,
|
|
838
|
+
whether the app accepted or refused it, and the app's own answer
|
|
839
|
+
("Opened the privacy screen.", "I can reach: settings, favourites, …").
|
|
840
|
+
- **What happened in the app** — the screens the customer moved through, the
|
|
841
|
+
taps you track with `useGraineTap`, and every friction signal, with whether
|
|
842
|
+
the agent spoke to it and, if not, why (it was mid-sentence, it had just
|
|
843
|
+
spoken, it had reached its limit).
|
|
844
|
+
- **Who it was** — from `identify()`.
|
|
845
|
+
|
|
846
|
+
All of it exports to CSV. Nothing needs to be instrumented beyond what the
|
|
847
|
+
screens already report.
|
|
848
|
+
|
|
849
|
+
## Captions on WebRTC
|
|
850
|
+
|
|
851
|
+
Captions — the agent's line and what the customer is saying — reach the host
|
|
852
|
+
on both transports. On WebRTC the audio carries no text, so the runtime
|
|
853
|
+
mirrors the same caption frames over the control channel; a native bar using
|
|
854
|
+
`onCaption` sees `role: "customer"` and `role: "agent"` lines either way.
|
|
855
|
+
|
|
856
|
+
---
|
|
857
|
+
|
|
768
858
|
## Coming from RevRag
|
|
769
859
|
|
|
770
860
|
Same shape, different names. If you have a RevRag integration, this is the
|
|
@@ -791,6 +881,7 @@ whole translation:
|
|
|
791
881
|
| `checkPermissions()` | `requestMicrophonePermission()` |
|
|
792
882
|
| server `widget_config` | `appearance` from the session, via `resolveNativeAppearance` |
|
|
793
883
|
| LiveKit native setup | none — see *Why there is no LiveKit step* |
|
|
884
|
+
| Friction detection (dashboard switch) | Per-screen JSON rules — `friction` on the provider, or Embed & Widgets → Friction; five named signals, `friction_detected` event |
|
|
794
885
|
|
|
795
886
|
Two things RevRag has that are deliberately absent: a LiveKit install step
|
|
796
887
|
(audio rides a WebView on a hosted page, so audio fixes ship without an app
|
|
@@ -809,7 +900,8 @@ told to ignore).
|
|
|
809
900
|
| `useGraineHighlight()` | let the agent point at an element on this screen |
|
|
810
901
|
| `useGraineWidget()` | the component the agent drew, and how to answer it |
|
|
811
902
|
| `useGraineTrack()` | product events that inform but do not interrupt |
|
|
812
|
-
| `useGraineEvents()` | conversation lifecycle |
|
|
903
|
+
| `useGraineEvents()` | conversation lifecycle, action outcomes, friction signals |
|
|
904
|
+
| `useGraineTap()` | wrap an `onPress` so the tap is tracked (and rage taps detected) |
|
|
813
905
|
| `useGraineVoice()` | only if you bring your own native audio instead of the WebView |
|
|
814
906
|
|
|
815
907
|
### `GraineProvider`
|
|
@@ -826,6 +918,20 @@ told to ignore).
|
|
|
826
918
|
| `autoConnect` | `boolean` | Default `true`. Set `false` when `GraineVoiceLauncher` owns the connection — see below. |
|
|
827
919
|
| `onProactive` | `(text) => void` | The agent spoke first; draw your own nudge. |
|
|
828
920
|
| `endOnBackground` | `boolean` | Default `true`. You want it on. |
|
|
921
|
+
| `appVersion` | `string` | Which build the conversation happened in. Lands on the call record. |
|
|
922
|
+
| `friction` | `FrictionConfig` | This app's friction rules, layered over the dashboard's. See *Friction detection*. |
|
|
923
|
+
|
|
924
|
+
### `GraineInAppClient`
|
|
925
|
+
|
|
926
|
+
| Method | For |
|
|
927
|
+
|---|---|
|
|
928
|
+
| `setScreen(ctx)` / `clearScreen(id)` | Report a screen; withdraw it only if it is still the current one. |
|
|
929
|
+
| `identify(user)` | Who the customer is. Masked on the way out. |
|
|
930
|
+
| `track(name, data)` / `reportEvent(event)` | Inform the agent / ask it to speak up. |
|
|
931
|
+
| `registerAction(name, handler)` / `executeAction(name, args)` | Implement and run in-app actions. |
|
|
932
|
+
| `configureFriction(rules)` / `friction` | Replace the app's friction layer / read the resolved rules. |
|
|
933
|
+
| `session()` | The dashboard config for this key: appearance, actions, friction. |
|
|
934
|
+
| `on(event, fn)` | `screen`, `action`, `action_result`, `friction`, `app_event`, `mic_denied`, … |
|
|
829
935
|
|
|
830
936
|
### `GraineVoiceLauncher`
|
|
831
937
|
|
package/README.md
CHANGED
|
@@ -219,11 +219,24 @@ requests a permission on your behalf.
|
|
|
219
219
|
| `useGraineScreen(context)` | Report what this screen shows. |
|
|
220
220
|
| `useGraineAction(name, handler)` | Implement one action the agent may call. |
|
|
221
221
|
| `useGraineVoice(adapter, opts)` | Microphone, playback and barge-in. |
|
|
222
|
+
| `useGraineEvents(fn)` | Conversation lifecycle, `action_completed`, `friction_detected`, `mic_denied`. |
|
|
223
|
+
| `useGraineTrack()` / `useGraineTap()` | Product events and taps the agent knows about but does not interrupt for. |
|
|
224
|
+
| `useGraineIdentify()` | Who the customer is — it reaches the greeting and the call record. |
|
|
225
|
+
| `useGraineHighlight()` / `useGraineWidget()` | Let the agent point at things and draw components. |
|
|
226
|
+
| `GraineVoiceLauncher` | The voice engine: WebView, microphone, WebRTC or WebSocket. |
|
|
222
227
|
| `GraineAgentBar` | Optional default UI. Mount at the root. Replace it with your own. |
|
|
223
228
|
| `addMaskRule(pattern, token)` | Mask an app-specific identifier format. |
|
|
224
229
|
|
|
230
|
+
**Friction detection** — the agent notices a stuck customer (idle, repeated
|
|
231
|
+
field errors, back-and-forth, rage taps, error banners) and speaks first. Rules
|
|
232
|
+
are JSON, per screen, configured in the dashboard; pass `friction` on the
|
|
233
|
+
provider only for the screens your app knows better. `DEFAULT_FRICTION`,
|
|
234
|
+
`resolveFriction` and `FrictionDetector` are exported for hosts that want the
|
|
235
|
+
engine on their own timers.
|
|
236
|
+
|
|
225
237
|
`GraineInAppClient` is exported for apps that want the transport without the
|
|
226
|
-
React layer
|
|
238
|
+
React layer: `setScreen`, `clearScreen`, `identify`, `track`, `reportEvent`,
|
|
239
|
+
`registerAction`, `executeAction`, `configureFriction`, `friction`, `session`.
|
|
227
240
|
|
|
228
241
|
## Support
|
|
229
242
|
|
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,7 +46,10 @@ 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;
|
|
@@ -53,6 +59,9 @@ export declare class GraineInAppClient {
|
|
|
53
59
|
private appVersion;
|
|
54
60
|
private recentEvents;
|
|
55
61
|
constructor(opts: GraineInAppOptions);
|
|
62
|
+
private effectiveFriction;
|
|
63
|
+
configureFriction(override: FrictionConfig | null | undefined): void;
|
|
64
|
+
get friction(): Required<FrictionConfig>;
|
|
56
65
|
on(event: string, fn: Listener): () => void;
|
|
57
66
|
emit(event: string, payload?: any): void;
|
|
58
67
|
private get fetch();
|
|
@@ -67,6 +76,7 @@ export declare class GraineInAppClient {
|
|
|
67
76
|
executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
|
|
68
77
|
private runAction;
|
|
69
78
|
private invokeAction;
|
|
79
|
+
private performAction;
|
|
70
80
|
get isConnected(): boolean;
|
|
71
81
|
getScreen(): ScreenContext | null;
|
|
72
82
|
getIdentity(): Record<string, string>;
|
|
@@ -81,7 +91,9 @@ export declare class GraineInAppClient {
|
|
|
81
91
|
highlight(name: string | null, ttlMs?: number): void;
|
|
82
92
|
registerAction(name: string, handler: ActionHandler): () => void;
|
|
83
93
|
get availableActions(): string[];
|
|
94
|
+
clearScreen(screenId: string | null | undefined): void;
|
|
84
95
|
setScreen(context: ScreenContext | null): void;
|
|
96
|
+
private dispatchFriction;
|
|
85
97
|
reportEvent(event: AppEvent): void;
|
|
86
98
|
track(name: string, data?: Record<string, unknown>): void;
|
|
87
99
|
private scheduleScreen;
|
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,7 +18,8 @@ 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;
|
|
@@ -31,6 +33,23 @@ export class GraineInAppClient {
|
|
|
31
33
|
if (!opts?.baseUrl)
|
|
32
34
|
throw new Error("[Graine] baseUrl is required.");
|
|
33
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;
|
|
34
53
|
}
|
|
35
54
|
on(event, fn) {
|
|
36
55
|
if (!this.listeners.has(event))
|
|
@@ -69,6 +88,9 @@ export class GraineInAppClient {
|
|
|
69
88
|
throw new Error(data?.error || `Embed rejected (${res.status})`);
|
|
70
89
|
}
|
|
71
90
|
this.config = data;
|
|
91
|
+
this.serverFriction = data && typeof data.friction === "object" ? data.friction : null;
|
|
92
|
+
this.detector.configure(this.effectiveFriction());
|
|
93
|
+
this.armStall();
|
|
72
94
|
this.reportActionMismatch();
|
|
73
95
|
return this.config;
|
|
74
96
|
}
|
|
@@ -263,6 +285,15 @@ export class GraineInAppClient {
|
|
|
263
285
|
this.send({ type: "app_action_result", action_id: request.action_id, ...result });
|
|
264
286
|
}
|
|
265
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) {
|
|
266
297
|
const handler = this.actions.get(request.name);
|
|
267
298
|
if (!handler) {
|
|
268
299
|
return {
|
|
@@ -332,16 +363,35 @@ export class GraineInAppClient {
|
|
|
332
363
|
get availableActions() {
|
|
333
364
|
return [...this.actions.keys()];
|
|
334
365
|
}
|
|
366
|
+
clearScreen(screenId) {
|
|
367
|
+
if (!this.screen)
|
|
368
|
+
return;
|
|
369
|
+
if (screenId && this.screen.screen !== screenId)
|
|
370
|
+
return;
|
|
371
|
+
this.setScreen(null);
|
|
372
|
+
}
|
|
335
373
|
setScreen(context) {
|
|
336
374
|
const changedScreen = context?.screen !== this.screen?.screen;
|
|
337
375
|
this.screen = context;
|
|
376
|
+
const now = Date.now();
|
|
338
377
|
if (changedScreen) {
|
|
339
|
-
this.screenSince =
|
|
340
|
-
this.stallReportedFor = null;
|
|
341
|
-
this.armStall();
|
|
378
|
+
this.screenSince = now;
|
|
342
379
|
}
|
|
380
|
+
this.dispatchFriction(this.detector.onScreen(context, now));
|
|
381
|
+
this.armStall();
|
|
343
382
|
this.scheduleScreen();
|
|
344
383
|
}
|
|
384
|
+
dispatchFriction(signals) {
|
|
385
|
+
for (const s of signals) {
|
|
386
|
+
this.emit("friction", s);
|
|
387
|
+
this.reportEvent({
|
|
388
|
+
name: s.name,
|
|
389
|
+
detail: s.detail,
|
|
390
|
+
cooldown_seconds: s.cooldown_seconds,
|
|
391
|
+
max_per_session: s.max_per_session,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
345
395
|
reportEvent(event) {
|
|
346
396
|
this.emit("app_event", event);
|
|
347
397
|
this.send({ type: "app_event", event });
|
|
@@ -352,6 +402,7 @@ export class GraineInAppClient {
|
|
|
352
402
|
this.recentEvents.push({ name, at: Date.now(), ...(data ? { data } : {}) });
|
|
353
403
|
if (this.recentEvents.length > 10)
|
|
354
404
|
this.recentEvents.shift();
|
|
405
|
+
this.dispatchFriction(this.detector.onTrack(name, Date.now()));
|
|
355
406
|
if (this.screen)
|
|
356
407
|
this.scheduleScreen();
|
|
357
408
|
}
|
|
@@ -402,24 +453,14 @@ export class GraineInAppClient {
|
|
|
402
453
|
}
|
|
403
454
|
armStall() {
|
|
404
455
|
clearTimeout(this.stallTimer);
|
|
405
|
-
const
|
|
406
|
-
if (
|
|
456
|
+
const due = this.detector.idleDueIn(Date.now());
|
|
457
|
+
if (due === null)
|
|
407
458
|
return;
|
|
408
|
-
const screenId = this.screen.screen;
|
|
409
459
|
this.stallTimer = setTimeout(() => {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const stuck = (this.screen?.fields ?? []).find((f) => f.status === "invalid" || f.error || f.status === "empty");
|
|
415
|
-
this.reportEvent({
|
|
416
|
-
name: "stalled_on_step",
|
|
417
|
-
detail: stuck
|
|
418
|
-
? `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s. ` +
|
|
419
|
-
`The "${stuck.label ?? stuck.name}" field is ${stuck.error ? `showing: ${stuck.error}` : stuck.status}.`
|
|
420
|
-
: `The customer has not touched the "${title}" screen for ${Math.round(after / 1000)}s.`,
|
|
421
|
-
});
|
|
422
|
-
}, after);
|
|
460
|
+
const signal = this.detector.onIdle(Date.now());
|
|
461
|
+
if (signal)
|
|
462
|
+
this.dispatchFriction([signal]);
|
|
463
|
+
}, due);
|
|
423
464
|
}
|
|
424
465
|
identify(user) {
|
|
425
466
|
if (!user) {
|
|
@@ -436,7 +477,9 @@ export class GraineInAppClient {
|
|
|
436
477
|
this.scheduleScreen();
|
|
437
478
|
}
|
|
438
479
|
noteInteraction() {
|
|
439
|
-
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
this.screenSince = now;
|
|
482
|
+
this.detector.onInteraction(now);
|
|
440
483
|
this.armStall();
|
|
441
484
|
}
|
|
442
485
|
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,5 +1,6 @@
|
|
|
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";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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";
|
|
@@ -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;
|
|
@@ -44,6 +45,7 @@ export type GraineEvent = {
|
|
|
44
45
|
type: "action_completed";
|
|
45
46
|
name: string;
|
|
46
47
|
status: string;
|
|
48
|
+
message?: string;
|
|
47
49
|
} | {
|
|
48
50
|
type: "muted";
|
|
49
51
|
muted: boolean;
|
|
@@ -56,6 +58,11 @@ export type GraineEvent = {
|
|
|
56
58
|
} | {
|
|
57
59
|
type: "launcher_shown";
|
|
58
60
|
screen: string | null;
|
|
61
|
+
} | {
|
|
62
|
+
type: "friction_detected";
|
|
63
|
+
name: string;
|
|
64
|
+
screen: string;
|
|
65
|
+
detail: string;
|
|
59
66
|
};
|
|
60
67
|
export interface GraineWidget {
|
|
61
68
|
id: string;
|
|
@@ -83,11 +90,12 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
|
|
|
83
90
|
current: any;
|
|
84
91
|
} | null;
|
|
85
92
|
appVersion?: string;
|
|
93
|
+
friction?: FrictionConfig;
|
|
86
94
|
includeScreens?: string[];
|
|
87
95
|
launcherDelayMs?: number;
|
|
88
96
|
visibility?: LauncherVisibility;
|
|
89
97
|
}
|
|
90
|
-
export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, appVersion, 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;
|
|
91
99
|
export declare function useGraineAgent(): GraineContextValue;
|
|
92
100
|
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
93
101
|
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
@@ -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, appVersion, 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 =
|
|
@@ -99,6 +99,8 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
99
99
|
};
|
|
100
100
|
}, [navigationRef, client]);
|
|
101
101
|
useEffect(() => { client.setAppVersion(appVersion); }, [client, appVersion]);
|
|
102
|
+
useEffect(() => { if (friction !== undefined)
|
|
103
|
+
client.configureFriction(friction); }, [client, friction]);
|
|
102
104
|
useEffect(() => {
|
|
103
105
|
if (!navigationRef?.current)
|
|
104
106
|
setLauncherVisible(true);
|
|
@@ -134,6 +136,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
134
136
|
})),
|
|
135
137
|
client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
|
|
136
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 ?? "") })),
|
|
137
140
|
client.on("muted", (m) => {
|
|
138
141
|
setMutedState(m);
|
|
139
142
|
emitEvent({ type: "muted", muted: m });
|
|
@@ -145,6 +148,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
145
148
|
emitEvent({ type: "agent_speaking", speaking });
|
|
146
149
|
}),
|
|
147
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
|
+
})),
|
|
148
157
|
client.on("transcript", ({ text, final }) => {
|
|
149
158
|
if (text)
|
|
150
159
|
setCaption({ role: "customer", text, live: !final });
|
|
@@ -323,7 +332,7 @@ export function useGraineScreen(context) {
|
|
|
323
332
|
const serialised = JSON.stringify(context ?? null);
|
|
324
333
|
useEffect(() => {
|
|
325
334
|
client.setScreen(context);
|
|
326
|
-
return () => client.
|
|
335
|
+
return () => client.clearScreen(context?.screen);
|
|
327
336
|
}, [client, serialised]);
|
|
328
337
|
}
|
|
329
338
|
export function useGraineHighlight(name) {
|
package/package.json
CHANGED