@graineai/inapp-react-native 0.19.0 → 0.20.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 CHANGED
@@ -389,6 +389,74 @@ nowhere. Save is what pushes the catalogue to the agent.
389
389
 
390
390
  ---
391
391
 
392
+ ## Step 4b — Show them where, and draw them things
393
+
394
+ ### Pointing at something on screen
395
+
396
+ "Tap Continue" is useless if the customer cannot find Continue.
397
+
398
+ ```tsx
399
+ const { highlighted } = useGraineHighlight("continue_button");
400
+
401
+ <Pressable style={[styles.btn, highlighted && styles.glow]}>
402
+ <Text>Continue</Text>
403
+ </Pressable>
404
+ ```
405
+
406
+ That is the whole integration. Registering the name tells the agent the element
407
+ exists and can be pointed at; the boolean tells you when it is being pointed at.
408
+
409
+ **The SDK does not draw the highlight**, deliberately — it does not own your
410
+ design language, and a ring that looks right in one app looks broken in the
411
+ next. You get a boolean and style it the way the rest of your app would.
412
+
413
+ Highlights expire after six seconds. One that stays lit because the
414
+ conversation moved on is worse than none: the customer keeps looking at the
415
+ wrong thing.
416
+
417
+ Declare `highlight_element` once on the agent, in the dashboard, alongside your
418
+ other actions. **You do not implement it** — the SDK answers it, checks the name
419
+ against what is actually registered on this screen, and refuses with the list of
420
+ real targets if the agent invents one.
421
+
422
+ ### Components, not just sentences
423
+
424
+ Sometimes a card, a set of choices or a three-field form serves the customer
425
+ better than another sentence. The agent decides, and sends one:
426
+
427
+ ```tsx
428
+ const { widget, submit, dismiss } = useGraineWidget();
429
+
430
+ if (widget?.kind === "choices") {
431
+ return (
432
+ <Choices
433
+ options={widget.data.options}
434
+ onPick={(o) => submit({ chosen: o.id }, `I'll go with ${o.label}.`)}
435
+ />
436
+ );
437
+ }
438
+ ```
439
+
440
+ `submit` takes **two** things and both matter: the structured answer your
441
+ backend wants, and a sentence for the conversation. Send only the first and the
442
+ customer taps a card, sees a tick, and the agent carries on as though nothing
443
+ happened.
444
+
445
+ `widget.fallbackText` is what the agent would have said instead. Render it if
446
+ you get a `kind` you do not handle — a component you cannot draw should still
447
+ move the conversation forward.
448
+
449
+ `dismiss()` is local only. A customer closing a card has not answered the
450
+ question, so the agent is not told; it will ask again in words, which is right.
451
+
452
+ <Callout>
453
+ Works on both transports. On `rtc` these travel the control channel beside
454
+ screen context and actions, for the same reason: they must arrive whole and in
455
+ order.
456
+ </Callout>
457
+
458
+ ---
459
+
392
460
  ## Step 5 — Product events, without interrupting
393
461
 
394
462
  ```tsx
@@ -705,6 +773,8 @@ and resamples in its output stage, which is where the artefacts came from.
705
773
  | `useGraineIdentify()` | who the customer is |
706
774
  | `useGraineScreen()` | what is on the screen |
707
775
  | `useGraineAction()` | one handler per declared action |
776
+ | `useGraineHighlight()` | let the agent point at an element on this screen |
777
+ | `useGraineWidget()` | the component the agent drew, and how to answer it |
708
778
  | `useGraineTrack()` | product events that inform but do not interrupt |
709
779
  | `useGraineEvents()` | conversation lifecycle |
710
780
  | `useGraineVoice()` | only if you bring your own native audio instead of the WebView |
package/dist/client.d.ts CHANGED
@@ -26,6 +26,7 @@ export declare class GraineInAppClient {
26
26
  private connecting;
27
27
  private listeners;
28
28
  private actions;
29
+ private highlightables;
29
30
  private screen;
30
31
  private screenSeq;
31
32
  private screenSince;
@@ -62,6 +63,9 @@ export declare class GraineInAppClient {
62
63
  data?: Record<string, unknown>;
63
64
  }>;
64
65
  getAvailableActions(): string[];
66
+ registerHighlightable(name: string): () => void;
67
+ getHighlightables(): string[];
68
+ highlight(name: string | null, ttlMs?: number): void;
65
69
  registerAction(name: string, handler: ActionHandler): () => void;
66
70
  get availableActions(): string[];
67
71
  setScreen(context: ScreenContext | null): void;
package/dist/client.js CHANGED
@@ -10,6 +10,7 @@ export class GraineInAppClient {
10
10
  this.connecting = null;
11
11
  this.listeners = new Map();
12
12
  this.actions = new Map();
13
+ this.highlightables = new Set();
13
14
  this.screen = null;
14
15
  this.screenSeq = 0;
15
16
  this.screenSince = 0;
@@ -274,6 +275,22 @@ export class GraineInAppClient {
274
275
  getAvailableActions() {
275
276
  return [...this.actions.keys()];
276
277
  }
278
+ registerHighlightable(name) {
279
+ if (!name)
280
+ return () => { };
281
+ this.highlightables.add(name);
282
+ this.scheduleScreen();
283
+ return () => {
284
+ this.highlightables.delete(name);
285
+ this.scheduleScreen();
286
+ };
287
+ }
288
+ getHighlightables() {
289
+ return [...this.highlightables];
290
+ }
291
+ highlight(name, ttlMs = 6000) {
292
+ this.emit("highlight", { name, ttlMs });
293
+ }
277
294
  registerAction(name, handler) {
278
295
  this.actions.set(name, handler);
279
296
  this.scheduleScreen();
@@ -336,6 +353,7 @@ export class GraineInAppClient {
336
353
  ...(this.screen ? { idle_ms: Date.now() - this.screenSince } : {}),
337
354
  ...(Object.keys(this.identity).length ? { user: this.identity } : {}),
338
355
  ...(this.recentEvents.length ? { recent_events: this.recentEvents } : {}),
356
+ ...(this.highlightables.size ? { highlightable: [...this.highlightables] } : {}),
339
357
  }),
340
358
  available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
341
359
  seq: ++this.screenSeq,
@@ -424,6 +442,7 @@ export class GraineInAppClient {
424
442
  return this.agentSpeaking;
425
443
  }
426
444
  submitWidget(widgetId, data, summary) {
445
+ this.emit("widget_result", { widget_id: widgetId, data, summary });
427
446
  this.send({ type: "widget_result", widget_id: widgetId, data });
428
447
  if (summary)
429
448
  this.say(summary);
@@ -50,6 +50,12 @@ export type GraineEvent = {
50
50
  type: "error";
51
51
  message: string;
52
52
  };
53
+ export interface GraineWidget {
54
+ id: string;
55
+ kind: string;
56
+ data: Record<string, unknown>;
57
+ fallbackText: string;
58
+ }
53
59
  export interface Caption {
54
60
  role: "agent" | "customer";
55
61
  text: string;
@@ -86,5 +92,13 @@ export declare function useGraineVoice(adapter: AudioAdapter | null, options?: V
86
92
  stop: () => Promise<void>;
87
93
  };
88
94
  export declare function useGraineScreen(context: ScreenContext | null): void;
95
+ export declare function useGraineHighlight(name: string): {
96
+ highlighted: boolean;
97
+ };
98
+ export declare function useGraineWidget(): {
99
+ widget: GraineWidget | null;
100
+ submit: (data: Record<string, unknown>, summary: string) => void;
101
+ dismiss: () => void;
102
+ };
89
103
  export declare function useGraineAction(name: string, handler: ActionHandler): void;
90
104
  export {};
@@ -148,6 +148,20 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
148
148
  ? { ...prev, text: prev.text + text }
149
149
  : { role: "agent", text, live: true })),
150
150
  ];
151
+ offs.push(client.registerAction("highlight_element", async (args) => {
152
+ const name = String(args?.name || args?.element || args?.target || "");
153
+ const known = client.getHighlightables();
154
+ if (!name || !known.includes(name)) {
155
+ return {
156
+ status: "refused",
157
+ message: known.length
158
+ ? `There is nothing called "${name}" on this screen. I can point at: ${known.join(", ")}.`
159
+ : "There is nothing on this screen I can point at.",
160
+ };
161
+ }
162
+ client.highlight(name, Number(args?.ttl_ms) || 6000);
163
+ return { status: "ok", message: `Highlighted ${name} for the customer.` };
164
+ }));
151
165
  if (autoConnect) {
152
166
  setConnecting(true);
153
167
  client.connect().catch((err) => {
@@ -296,6 +310,49 @@ export function useGraineScreen(context) {
296
310
  return () => client.setScreen(null);
297
311
  }, [client, serialised]);
298
312
  }
313
+ export function useGraineHighlight(name) {
314
+ const { client } = useGraine();
315
+ const [highlighted, setHighlighted] = useState(false);
316
+ useEffect(() => client.registerHighlightable(name), [client, name]);
317
+ useEffect(() => {
318
+ let timer = null;
319
+ const off = client.on("highlight", (e) => {
320
+ const on = e?.name === name;
321
+ setHighlighted(on);
322
+ if (timer) {
323
+ clearTimeout(timer);
324
+ timer = null;
325
+ }
326
+ if (on)
327
+ timer = setTimeout(() => setHighlighted(false), Number(e?.ttlMs) || 6000);
328
+ });
329
+ return () => { if (timer)
330
+ clearTimeout(timer); off(); };
331
+ }, [client, name]);
332
+ return { highlighted };
333
+ }
334
+ export function useGraineWidget() {
335
+ const { client } = useGraine();
336
+ const [widget, setWidget] = useState(null);
337
+ useEffect(() => client.on("widget", (w) => {
338
+ if (!w?.widget_id)
339
+ return;
340
+ setWidget({
341
+ id: String(w.widget_id),
342
+ kind: String(w.kind || ""),
343
+ data: (w.data || {}),
344
+ fallbackText: String(w.fallback_text || ""),
345
+ });
346
+ }), [client]);
347
+ const submit = useCallback((data, summary) => {
348
+ if (!widget)
349
+ return;
350
+ client.submitWidget(widget.id, data, summary);
351
+ setWidget(null);
352
+ }, [client, widget]);
353
+ const dismiss = useCallback(() => setWidget(null), []);
354
+ return { widget, submit, dismiss };
355
+ }
299
356
  export function useGraineAction(name, handler) {
300
357
  const { client } = useGraine();
301
358
  const handlerRef = useRef(handler);
@@ -95,6 +95,18 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
95
95
  post({ type: "graine:app-event", name, data: rest });
96
96
  });
97
97
  }, [client, connected, post, pushContext]);
98
+ useEffect(() => {
99
+ if (!connected)
100
+ return;
101
+ return client.on("widget_result", (r) => {
102
+ post({
103
+ type: "graine:widget-result",
104
+ widgetId: r?.widget_id,
105
+ data: r?.data ?? {},
106
+ summary: r?.summary ?? "",
107
+ });
108
+ });
109
+ }, [client, connected, post]);
98
110
  const onMessage = useCallback(async (e) => {
99
111
  let msg;
100
112
  try {
@@ -145,6 +157,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
145
157
  case "graine:mic-denied":
146
158
  onMicDenied?.(String(msg.reason || "denied"));
147
159
  return;
160
+ case "graine:widget": {
161
+ client.emit?.("widget", msg.widget || {});
162
+ return;
163
+ }
148
164
  case "graine:app-action": {
149
165
  const action = msg.action || {};
150
166
  const result = await client.executeAction(action.name, action.arguments || {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "description": "Graine in-app agent for React Native \u2014 an agent that sees the screen your customer is on and can act on it.",
6
6
  "license": "MIT",