@graineai/inapp-react-native 0.33.6 → 0.35.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/dist/client.d.ts +19 -1
- package/dist/client.js +108 -10
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -1
- package/dist/protocol.d.ts +30 -0
- package/dist/protocol.js +128 -0
- package/dist/react-native/index.d.ts +40 -1
- package/dist/react-native/index.js +86 -3
- package/dist/react-native/keyboard-math.d.ts +1 -0
- package/dist/react-native/keyboard-math.js +6 -0
- package/dist/react-native/keyboard.d.ts +3 -0
- package/dist/react-native/keyboard.js +21 -0
- package/dist/react-native/placement-math.d.ts +10 -0
- package/dist/react-native/placement-math.js +15 -0
- package/dist/react-native/placement.d.ts +16 -0
- package/dist/react-native/placement.js +62 -0
- package/dist/react-native/ui.d.ts +8 -1
- package/dist/react-native/ui.js +32 -13
- package/dist/react-native/voice-launcher.d.ts +8 -1
- package/dist/react-native/voice-launcher.js +88 -12
- package/package.json +2 -2
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext, type ScreenField } from "./protocol.js";
|
|
1
|
+
import { type ConnectionQuality, type QualitySample, type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext, type ScreenField, type AgentSurface } from "./protocol.js";
|
|
2
2
|
import { type FrictionConfig } from "./friction.js";
|
|
3
3
|
export declare const SUBPROTOCOL = "graine.embed.v1";
|
|
4
4
|
export interface GraineInAppOptions {
|
|
@@ -25,6 +25,7 @@ export interface SessionConfig {
|
|
|
25
25
|
name?: string;
|
|
26
26
|
}>;
|
|
27
27
|
friction?: FrictionConfig;
|
|
28
|
+
pageVersion?: string;
|
|
28
29
|
}
|
|
29
30
|
export interface ActionDiagnosis {
|
|
30
31
|
usable: string[];
|
|
@@ -56,6 +57,16 @@ export declare class GraineInAppClient {
|
|
|
56
57
|
private muted;
|
|
57
58
|
private playoutClock;
|
|
58
59
|
private identity;
|
|
60
|
+
private runtimeVariables;
|
|
61
|
+
private quality;
|
|
62
|
+
private network;
|
|
63
|
+
allVariables(): Record<string, string>;
|
|
64
|
+
getVariables(): Record<string, string>;
|
|
65
|
+
setVariables(vars: Record<string, unknown> | null): void;
|
|
66
|
+
get connectionQuality(): ConnectionQuality;
|
|
67
|
+
get networkState(): "online" | "offline" | "unknown";
|
|
68
|
+
reportQuality(sample: QualitySample | null): ConnectionQuality;
|
|
69
|
+
reportNetwork(online: boolean): void;
|
|
59
70
|
private appVersion;
|
|
60
71
|
private recentEvents;
|
|
61
72
|
constructor(opts: GraineInAppOptions);
|
|
@@ -66,6 +77,12 @@ export declare class GraineInAppClient {
|
|
|
66
77
|
private lastWidget;
|
|
67
78
|
get pendingWidget(): any;
|
|
68
79
|
clearPendingWidget(): void;
|
|
80
|
+
private voiceHost;
|
|
81
|
+
get hasVoiceHost(): boolean;
|
|
82
|
+
setVoiceHost(on: boolean): void;
|
|
83
|
+
private currentSurface;
|
|
84
|
+
get surface(): AgentSurface;
|
|
85
|
+
setSurface(next: AgentSurface): void;
|
|
69
86
|
emit(event: string, payload?: any): void;
|
|
70
87
|
private get fetch();
|
|
71
88
|
session(): Promise<SessionConfig>;
|
|
@@ -73,6 +90,7 @@ export declare class GraineInAppClient {
|
|
|
73
90
|
private reportActionMismatch;
|
|
74
91
|
private ticket;
|
|
75
92
|
connect(): Promise<void>;
|
|
93
|
+
private get socketIsVoice();
|
|
76
94
|
private openSocket;
|
|
77
95
|
private send;
|
|
78
96
|
private onFrame;
|
package/dist/client.js
CHANGED
|
@@ -1,9 +1,66 @@
|
|
|
1
|
-
import { ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
|
|
1
|
+
import { contextToken, readAgentChunk, variablesForTicket, classifyQuality, ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
|
|
2
2
|
import { maskDeep } from "./mask.js";
|
|
3
3
|
import { FrictionDetector, resolveFriction } from "./friction.js";
|
|
4
4
|
export const SUBPROTOCOL = "graine.embed.v1";
|
|
5
5
|
const PING_MS = 25000;
|
|
6
6
|
export class GraineInAppClient {
|
|
7
|
+
allVariables() {
|
|
8
|
+
return {
|
|
9
|
+
...(this.config?.variables ?? {}),
|
|
10
|
+
...(this.opts.variables ?? {}),
|
|
11
|
+
...this.runtimeVariables,
|
|
12
|
+
...this.identity,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
getVariables() {
|
|
16
|
+
return this.allVariables();
|
|
17
|
+
}
|
|
18
|
+
setVariables(vars) {
|
|
19
|
+
if (!vars) {
|
|
20
|
+
this.runtimeVariables = {};
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const next = { ...this.runtimeVariables };
|
|
24
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
25
|
+
if (!k)
|
|
26
|
+
continue;
|
|
27
|
+
if (v === null || v === undefined) {
|
|
28
|
+
delete next[k];
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (typeof v === "object")
|
|
32
|
+
continue;
|
|
33
|
+
next[k] = String(v);
|
|
34
|
+
}
|
|
35
|
+
this.runtimeVariables = maskDeep(next);
|
|
36
|
+
}
|
|
37
|
+
this.emit("variables", this.allVariables());
|
|
38
|
+
}
|
|
39
|
+
get connectionQuality() {
|
|
40
|
+
return this.quality;
|
|
41
|
+
}
|
|
42
|
+
get networkState() {
|
|
43
|
+
return this.network;
|
|
44
|
+
}
|
|
45
|
+
reportQuality(sample) {
|
|
46
|
+
const next = sample ? classifyQuality(sample) : "unknown";
|
|
47
|
+
if (next !== this.quality) {
|
|
48
|
+
this.quality = next;
|
|
49
|
+
this.emit("quality", { quality: next, ...(sample ?? {}) });
|
|
50
|
+
}
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
53
|
+
reportNetwork(online) {
|
|
54
|
+
const next = online ? "online" : "offline";
|
|
55
|
+
if (next === this.network)
|
|
56
|
+
return;
|
|
57
|
+
this.network = next;
|
|
58
|
+
if (!online && this.quality !== "lost") {
|
|
59
|
+
this.quality = "lost";
|
|
60
|
+
this.emit("quality", { quality: "lost" });
|
|
61
|
+
}
|
|
62
|
+
this.emit("network", { online });
|
|
63
|
+
}
|
|
7
64
|
constructor(opts) {
|
|
8
65
|
this.config = null;
|
|
9
66
|
this.transport = null;
|
|
@@ -26,9 +83,14 @@ export class GraineInAppClient {
|
|
|
26
83
|
this.muted = false;
|
|
27
84
|
this.playoutClock = null;
|
|
28
85
|
this.identity = {};
|
|
86
|
+
this.runtimeVariables = {};
|
|
87
|
+
this.quality = "unknown";
|
|
88
|
+
this.network = "unknown";
|
|
29
89
|
this.appVersion = null;
|
|
30
90
|
this.recentEvents = [];
|
|
31
91
|
this.lastWidget = null;
|
|
92
|
+
this.voiceHost = false;
|
|
93
|
+
this.currentSurface = "voice";
|
|
32
94
|
if (!opts?.publishableKey)
|
|
33
95
|
throw new Error("[Graine] publishableKey is required.");
|
|
34
96
|
if (!opts?.baseUrl)
|
|
@@ -64,6 +126,26 @@ export class GraineInAppClient {
|
|
|
64
126
|
clearPendingWidget() {
|
|
65
127
|
this.lastWidget = null;
|
|
66
128
|
}
|
|
129
|
+
get hasVoiceHost() {
|
|
130
|
+
return this.voiceHost;
|
|
131
|
+
}
|
|
132
|
+
setVoiceHost(on) {
|
|
133
|
+
if (this.voiceHost === on)
|
|
134
|
+
return;
|
|
135
|
+
this.voiceHost = on;
|
|
136
|
+
this.emit("voice_host", { present: on });
|
|
137
|
+
}
|
|
138
|
+
get surface() {
|
|
139
|
+
return this.currentSurface;
|
|
140
|
+
}
|
|
141
|
+
setSurface(next) {
|
|
142
|
+
if (next !== "voice" && next !== "chat")
|
|
143
|
+
return;
|
|
144
|
+
if (this.currentSurface === next)
|
|
145
|
+
return;
|
|
146
|
+
this.currentSurface = next;
|
|
147
|
+
this.emit("surface", { surface: next });
|
|
148
|
+
}
|
|
67
149
|
emit(event, payload) {
|
|
68
150
|
if (event === "widget")
|
|
69
151
|
this.lastWidget = payload ?? null;
|
|
@@ -138,7 +220,8 @@ export class GraineInAppClient {
|
|
|
138
220
|
headers: { "Content-Type": "application/json" },
|
|
139
221
|
body: JSON.stringify({
|
|
140
222
|
publishableKey: this.opts.publishableKey,
|
|
141
|
-
mode: this.
|
|
223
|
+
mode: this.socketIsVoice ? "voice" : "text",
|
|
224
|
+
metadata: variablesForTicket(this.allVariables()),
|
|
142
225
|
}),
|
|
143
226
|
});
|
|
144
227
|
if (!res.ok)
|
|
@@ -157,6 +240,9 @@ export class GraineInAppClient {
|
|
|
157
240
|
this.connecting = this.openSocket().finally(() => { this.connecting = null; });
|
|
158
241
|
return this.connecting;
|
|
159
242
|
}
|
|
243
|
+
get socketIsVoice() {
|
|
244
|
+
return !!this.opts.voice && this.currentSurface !== "chat";
|
|
245
|
+
}
|
|
160
246
|
async openSocket() {
|
|
161
247
|
if (!this.config)
|
|
162
248
|
await this.session();
|
|
@@ -171,10 +257,13 @@ export class GraineInAppClient {
|
|
|
171
257
|
}
|
|
172
258
|
else {
|
|
173
259
|
const host = (this.config.wsHost || "wss://ws.graine.ai").replace(/\/$/, "");
|
|
174
|
-
const mode = this.
|
|
260
|
+
const mode = this.socketIsVoice ? "webcall=1" : "text_chat=1";
|
|
175
261
|
url = `${host}/chat/v1/${encodeURIComponent(agentId)}?${mode}`;
|
|
176
262
|
this.transport = "direct";
|
|
177
263
|
}
|
|
264
|
+
const token = contextToken(this.allVariables(), this.socketIsVoice ? "webcall" : "text_chat");
|
|
265
|
+
if (token)
|
|
266
|
+
url += `${url.includes("?") ? "&" : "?"}context_token=${encodeURIComponent(token)}`;
|
|
178
267
|
const WS = this.opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
179
268
|
const ws = new WS(url, protocols);
|
|
180
269
|
this.ws = ws;
|
|
@@ -199,11 +288,7 @@ export class GraineInAppClient {
|
|
|
199
288
|
event: "start",
|
|
200
289
|
meta_data: {
|
|
201
290
|
client: "graine-inapp-sdk",
|
|
202
|
-
context_data:
|
|
203
|
-
...(this.config.variables ?? {}),
|
|
204
|
-
...(this.opts.variables ?? {}),
|
|
205
|
-
...this.identity,
|
|
206
|
-
},
|
|
291
|
+
context_data: this.allVariables(),
|
|
207
292
|
},
|
|
208
293
|
});
|
|
209
294
|
this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
|
|
@@ -232,9 +317,20 @@ export class GraineInAppClient {
|
|
|
232
317
|
return;
|
|
233
318
|
}
|
|
234
319
|
switch (msg.type) {
|
|
235
|
-
case "text":
|
|
236
|
-
|
|
320
|
+
case "text": {
|
|
321
|
+
const chunk = readAgentChunk(msg.data);
|
|
322
|
+
if (chunk.startsTurn && !this.agentSpeaking) {
|
|
323
|
+
this.agentSpeaking = true;
|
|
324
|
+
this.emit("agent_speaking", true);
|
|
325
|
+
}
|
|
326
|
+
if (chunk.text)
|
|
327
|
+
this.emit("chunk", chunk.text);
|
|
328
|
+
if (chunk.endsTurn && this.agentSpeaking) {
|
|
329
|
+
this.agentSpeaking = false;
|
|
330
|
+
this.emit("agent_speaking", false);
|
|
331
|
+
}
|
|
237
332
|
break;
|
|
333
|
+
}
|
|
238
334
|
case "widget":
|
|
239
335
|
this.emit("widget", msg.widget);
|
|
240
336
|
break;
|
|
@@ -526,6 +622,7 @@ export class GraineInAppClient {
|
|
|
526
622
|
identify(user) {
|
|
527
623
|
if (!user) {
|
|
528
624
|
this.identity = {};
|
|
625
|
+
this.emit("variables", this.allVariables());
|
|
529
626
|
return;
|
|
530
627
|
}
|
|
531
628
|
const flat = {};
|
|
@@ -535,6 +632,7 @@ export class GraineInAppClient {
|
|
|
535
632
|
flat[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
536
633
|
}
|
|
537
634
|
this.identity = maskDeep(flat);
|
|
635
|
+
this.emit("variables", this.allVariables());
|
|
538
636
|
this.scheduleScreen();
|
|
539
637
|
}
|
|
540
638
|
noteInteraction() {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,11 @@ export { LOADER_KINDS, LOADER_SIZES, LOADER_SPEEDS, LOADER_MOMENTS, DEFAULT_LOAD
|
|
|
6
6
|
export type { LoaderKind, LoaderSize, LoaderSpeed, LoaderMoment, LoaderText, LoaderStyle, LoaderLook } from "./protocol.js";
|
|
7
7
|
export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall, NAVIGATE_ACTION, SELECT_ARG_MAX, SELECT_VALUE_MAX } from "./protocol.js";
|
|
8
8
|
export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
|
|
9
|
+
export { contextToken } from "./protocol.js";
|
|
10
|
+
export { resolveSurface } from "./protocol.js";
|
|
11
|
+
export type { AgentSurface, SurfacePolicy } from "./protocol.js";
|
|
12
|
+
export { classifyQuality, variablesForTicket, TICKET_VARIABLE_LIMITS, readAgentChunk, STREAM_START, STREAM_END } from "./protocol.js";
|
|
13
|
+
export type { ConnectionQuality, QualitySample, AgentChunk } from "./protocol.js";
|
|
9
14
|
export type { WidgetSelect } from "./protocol.js";
|
|
10
15
|
export type { WidgetSelectAction } from "./protocol.js";
|
|
11
16
|
export { GraineWidget } from "./react-native/widget.js";
|
|
@@ -18,7 +23,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type Fri
|
|
|
18
23
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
|
|
19
24
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
|
|
20
25
|
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
21
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
|
|
26
|
+
export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
|
|
22
27
|
export { requestMicrophonePermission } from "./react-native/permissions.js";
|
|
23
28
|
export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, type GraineFabProps, type GraineTouchCaptureProps, } from "./react-native/fab.js";
|
|
24
29
|
export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,9 @@ export { WIDGET_PRESETS, WIDGET_IMAGE_SIZES, WIDGET_DENSITIES, WIDGET_BUTTONS, W
|
|
|
3
3
|
export { LOADER_KINDS, LOADER_SIZES, LOADER_SPEEDS, LOADER_MOMENTS, DEFAULT_LOADER_KIND, DEFAULT_LOADER_TEXT, LOADER_TEXT_MAX, normaliseLoaderStyle, resolveLoader, } from "./protocol.js";
|
|
4
4
|
export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall, NAVIGATE_ACTION, SELECT_ARG_MAX, SELECT_VALUE_MAX } from "./protocol.js";
|
|
5
5
|
export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
|
|
6
|
+
export { contextToken } from "./protocol.js";
|
|
7
|
+
export { resolveSurface } from "./protocol.js";
|
|
8
|
+
export { classifyQuality, variablesForTicket, TICKET_VARIABLE_LIMITS, readAgentChunk, STREAM_START, STREAM_END } from "./protocol.js";
|
|
6
9
|
export { GraineWidget } from "./react-native/widget.js";
|
|
7
10
|
export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
|
|
8
11
|
export { PENDING_WIDGET_MS, AWAITING_REPLY_MS, MIN_LOADER_MS } from "./react-native/index.js";
|
|
@@ -11,7 +14,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "
|
|
|
11
14
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
|
|
12
15
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
|
|
13
16
|
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
14
|
-
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, } from "./react-native/index.js";
|
|
17
|
+
export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, } from "./react-native/index.js";
|
|
15
18
|
export { requestMicrophonePermission } from "./react-native/permissions.js";
|
|
16
19
|
export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, } from "./react-native/fab.js";
|
|
17
20
|
export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -179,3 +179,33 @@ export declare function selectCall(onSelect: WidgetSelectAction | undefined | nu
|
|
|
179
179
|
args: Record<string, string>;
|
|
180
180
|
} | null;
|
|
181
181
|
export declare function fillSelectArgs(args: Record<string, string> | undefined, item: Record<string, unknown>): Record<string, string>;
|
|
182
|
+
export declare function contextToken(variables: Record<string, unknown> | null | undefined, callType?: string): string | null;
|
|
183
|
+
export type AgentSurface = "voice" | "chat";
|
|
184
|
+
export interface SurfacePolicy {
|
|
185
|
+
allowed: AgentSurface[];
|
|
186
|
+
start: AgentSurface;
|
|
187
|
+
canSwitch: boolean;
|
|
188
|
+
}
|
|
189
|
+
export declare function resolveSurface(appearance?: Record<string, any> | null, prefer?: AgentSurface | null): SurfacePolicy;
|
|
190
|
+
export declare const STREAM_START = "<beginning_of_stream>";
|
|
191
|
+
export declare const STREAM_END = "<end_of_stream>";
|
|
192
|
+
export interface AgentChunk {
|
|
193
|
+
text: string;
|
|
194
|
+
startsTurn: boolean;
|
|
195
|
+
endsTurn: boolean;
|
|
196
|
+
}
|
|
197
|
+
export declare function readAgentChunk(raw: unknown): AgentChunk;
|
|
198
|
+
export type ConnectionQuality = "excellent" | "good" | "poor" | "lost" | "unknown";
|
|
199
|
+
export interface QualitySample {
|
|
200
|
+
rtt?: number | null;
|
|
201
|
+
jitter?: number | null;
|
|
202
|
+
loss?: number | null;
|
|
203
|
+
}
|
|
204
|
+
export declare function classifyQuality(s: QualitySample | null | undefined): ConnectionQuality;
|
|
205
|
+
export declare const TICKET_VARIABLE_LIMITS: {
|
|
206
|
+
readonly keys: 12;
|
|
207
|
+
readonly keyLength: 40;
|
|
208
|
+
readonly valueLength: 200;
|
|
209
|
+
readonly totalBytes: 1024;
|
|
210
|
+
};
|
|
211
|
+
export declare function variablesForTicket(vars: Record<string, unknown> | null | undefined): Record<string, string> | undefined;
|
package/dist/protocol.js
CHANGED
|
@@ -261,3 +261,131 @@ export function fillSelectArgs(args, item) {
|
|
|
261
261
|
out[k] = fillSelectTemplate(v, item);
|
|
262
262
|
return out;
|
|
263
263
|
}
|
|
264
|
+
export function contextToken(variables, callType = "webcall") {
|
|
265
|
+
const vals = {};
|
|
266
|
+
for (const [k, v] of Object.entries(variables ?? {})) {
|
|
267
|
+
if (v === null || v === undefined || typeof v === "object")
|
|
268
|
+
continue;
|
|
269
|
+
const key = String(k).trim();
|
|
270
|
+
if (key)
|
|
271
|
+
vals[key] = typeof v === "string" ? v : String(v);
|
|
272
|
+
}
|
|
273
|
+
if (!Object.keys(vals).length)
|
|
274
|
+
return null;
|
|
275
|
+
const payload = { ...vals, call_type: callType, recipient_data: { ...vals, call_type: callType } };
|
|
276
|
+
try {
|
|
277
|
+
return base64(JSON.stringify(payload));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
284
|
+
function base64(text) {
|
|
285
|
+
const bytes = [];
|
|
286
|
+
for (let i = 0; i < text.length; i++) {
|
|
287
|
+
let c = text.charCodeAt(i);
|
|
288
|
+
if (c < 0x80)
|
|
289
|
+
bytes.push(c);
|
|
290
|
+
else if (c < 0x800)
|
|
291
|
+
bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
|
292
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
|
|
293
|
+
const lo = text.charCodeAt(++i);
|
|
294
|
+
c = 0x10000 + ((c - 0xd800) << 10) + (lo - 0xdc00);
|
|
295
|
+
bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
|
296
|
+
}
|
|
297
|
+
else
|
|
298
|
+
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
|
299
|
+
}
|
|
300
|
+
let out = "";
|
|
301
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
302
|
+
const b0 = bytes[i], b1 = bytes[i + 1], b2 = bytes[i + 2];
|
|
303
|
+
out += B64[b0 >> 2];
|
|
304
|
+
out += B64[((b0 & 3) << 4) | ((b1 ?? 0) >> 4)];
|
|
305
|
+
out += b1 === undefined ? "=" : B64[((b1 & 15) << 2) | ((b2 ?? 0) >> 6)];
|
|
306
|
+
out += b2 === undefined ? "=" : B64[b2 & 63];
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
export function resolveSurface(appearance, prefer) {
|
|
311
|
+
const mode = String(appearance?.mode ?? "both").toLowerCase();
|
|
312
|
+
const allowed = mode === "voice" ? ["voice"] : mode === "chat" ? ["chat"] : ["voice", "chat"];
|
|
313
|
+
const configured = String(appearance?.start ?? "").toLowerCase();
|
|
314
|
+
const start = prefer && allowed.includes(prefer)
|
|
315
|
+
? prefer
|
|
316
|
+
: configured === "chat" || configured === "voice"
|
|
317
|
+
? allowed.includes(configured)
|
|
318
|
+
? configured
|
|
319
|
+
: allowed[0]
|
|
320
|
+
: allowed[0];
|
|
321
|
+
return { allowed, start, canSwitch: allowed.length > 1 };
|
|
322
|
+
}
|
|
323
|
+
export const STREAM_START = "<beginning_of_stream>";
|
|
324
|
+
export const STREAM_END = "<end_of_stream>";
|
|
325
|
+
export function readAgentChunk(raw) {
|
|
326
|
+
const text = raw == null ? "" : String(raw);
|
|
327
|
+
if (!text)
|
|
328
|
+
return { text: "", startsTurn: false, endsTurn: false };
|
|
329
|
+
if (text.indexOf("<") === -1)
|
|
330
|
+
return { text, startsTurn: false, endsTurn: false };
|
|
331
|
+
const lower = text.toLowerCase();
|
|
332
|
+
const startsTurn = lower.includes(STREAM_START);
|
|
333
|
+
const endsTurn = lower.includes(STREAM_END);
|
|
334
|
+
if (!startsTurn && !endsTurn)
|
|
335
|
+
return { text, startsTurn: false, endsTurn: false };
|
|
336
|
+
return {
|
|
337
|
+
text: text.replace(STREAM_START_RE, "").replace(STREAM_END_RE, ""),
|
|
338
|
+
startsTurn,
|
|
339
|
+
endsTurn,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const STREAM_START_RE = /<beginning_of_stream>/gi;
|
|
343
|
+
const STREAM_END_RE = /<end_of_stream>/gi;
|
|
344
|
+
export function classifyQuality(s) {
|
|
345
|
+
if (!s)
|
|
346
|
+
return "unknown";
|
|
347
|
+
const rtt = num(s.rtt), jitter = num(s.jitter), loss = num(s.loss);
|
|
348
|
+
if (rtt === null && jitter === null && loss === null)
|
|
349
|
+
return "unknown";
|
|
350
|
+
if ((rtt ?? 0) >= 400 || (jitter ?? 0) >= 80 || (loss ?? 0) >= 0.08)
|
|
351
|
+
return "poor";
|
|
352
|
+
if ((rtt ?? 0) >= 150 || (jitter ?? 0) >= 30 || (loss ?? 0) >= 0.02)
|
|
353
|
+
return "good";
|
|
354
|
+
return "excellent";
|
|
355
|
+
}
|
|
356
|
+
function num(v) {
|
|
357
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
358
|
+
}
|
|
359
|
+
export const TICKET_VARIABLE_LIMITS = { keys: 12, keyLength: 40, valueLength: 200, totalBytes: 1024 };
|
|
360
|
+
export function variablesForTicket(vars) {
|
|
361
|
+
if (!vars || typeof vars !== "object" || Array.isArray(vars))
|
|
362
|
+
return undefined;
|
|
363
|
+
const out = {};
|
|
364
|
+
let n = 0;
|
|
365
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
366
|
+
if (n >= TICKET_VARIABLE_LIMITS.keys)
|
|
367
|
+
break;
|
|
368
|
+
if (v === null || v === undefined || typeof v === "object")
|
|
369
|
+
continue;
|
|
370
|
+
const key = String(k).slice(0, TICKET_VARIABLE_LIMITS.keyLength);
|
|
371
|
+
const value = String(v).slice(0, TICKET_VARIABLE_LIMITS.valueLength);
|
|
372
|
+
if (!key || !value)
|
|
373
|
+
continue;
|
|
374
|
+
out[key] = value;
|
|
375
|
+
n++;
|
|
376
|
+
}
|
|
377
|
+
if (n === 0)
|
|
378
|
+
return undefined;
|
|
379
|
+
let bytes = 2;
|
|
380
|
+
for (const [k, v] of Object.entries(out))
|
|
381
|
+
bytes += utf8Length(k) + utf8Length(v) + 6;
|
|
382
|
+
return bytes > TICKET_VARIABLE_LIMITS.totalBytes ? undefined : out;
|
|
383
|
+
}
|
|
384
|
+
function utf8Length(s) {
|
|
385
|
+
let n = 0;
|
|
386
|
+
for (let i = 0; i < s.length; i++) {
|
|
387
|
+
const c = s.charCodeAt(i);
|
|
388
|
+
n += c < 0x80 ? 1 : c < 0x800 ? 2 : c >= 0xd800 && c <= 0xdbff ? (i++, 4) : 3;
|
|
389
|
+
}
|
|
390
|
+
return n;
|
|
391
|
+
}
|
|
@@ -5,8 +5,17 @@ import type { ActionHandler, ScreenContext, ScreenField } from "../protocol.js";
|
|
|
5
5
|
import { type WidgetStyle, type WidgetSelectAction } from "../protocol.js";
|
|
6
6
|
export { GraineFab, GraineTouchCapture } from "./fab.js";
|
|
7
7
|
export type { GraineFabProps, GraineTouchCaptureProps } from "./fab.js";
|
|
8
|
+
export { useKeyboardLift, liftFor } from "./keyboard.js";
|
|
9
|
+
export { useDraggablePlacement } from "./placement.js";
|
|
10
|
+
export type { DraggablePlacement, DraggablePlacementOptions } from "./placement.js";
|
|
11
|
+
export { clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX } from "./placement-math.js";
|
|
12
|
+
export type { PlacementBounds } from "./placement-math.js";
|
|
8
13
|
export { widgetSurface } from "../protocol.js";
|
|
9
14
|
export type { WidgetSurface } from "../protocol.js";
|
|
15
|
+
export { resolveSurface } from "../protocol.js";
|
|
16
|
+
export type { AgentSurface, SurfacePolicy } from "../protocol.js";
|
|
17
|
+
import { type AgentSurface, type ConnectionQuality } from "../protocol.js";
|
|
18
|
+
export type { ConnectionQuality, QualitySample } from "../protocol.js";
|
|
10
19
|
import type { FrictionConfig } from "../friction.js";
|
|
11
20
|
import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
|
|
12
21
|
interface GraineContextValue {
|
|
@@ -31,6 +40,13 @@ interface GraineContextValue {
|
|
|
31
40
|
onEvent: (fn: (e: GraineEvent) => void) => () => void;
|
|
32
41
|
track: (name: string, data?: Record<string, unknown>) => void;
|
|
33
42
|
identify: (user: Record<string, unknown> | null) => void;
|
|
43
|
+
surface: AgentSurface;
|
|
44
|
+
surfaces: AgentSurface[];
|
|
45
|
+
canSwitch: boolean;
|
|
46
|
+
setSurface: (surface: AgentSurface) => void;
|
|
47
|
+
connectionQuality: ConnectionQuality;
|
|
48
|
+
network: "online" | "offline" | "unknown";
|
|
49
|
+
setVariables: (vars: Record<string, unknown> | null) => void;
|
|
34
50
|
}
|
|
35
51
|
export type GraineEvent = {
|
|
36
52
|
type: "conversation_started";
|
|
@@ -68,6 +84,15 @@ export type GraineEvent = {
|
|
|
68
84
|
name: string;
|
|
69
85
|
screen: string;
|
|
70
86
|
detail: string;
|
|
87
|
+
} | {
|
|
88
|
+
type: "connection_quality";
|
|
89
|
+
quality: ConnectionQuality;
|
|
90
|
+
rtt?: number;
|
|
91
|
+
jitter?: number;
|
|
92
|
+
loss?: number;
|
|
93
|
+
} | {
|
|
94
|
+
type: "network";
|
|
95
|
+
online: boolean;
|
|
71
96
|
};
|
|
72
97
|
export declare const PENDING_WIDGET_MS = 12000;
|
|
73
98
|
export declare const AWAITING_REPLY_MS = 12000;
|
|
@@ -97,6 +122,8 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
|
|
|
97
122
|
children: React.ReactNode;
|
|
98
123
|
client?: GraineInAppClient;
|
|
99
124
|
autoConnect?: boolean;
|
|
125
|
+
surface?: AgentSurface;
|
|
126
|
+
onSurfaceChange?: (surface: AgentSurface) => void;
|
|
100
127
|
onProactive?: (text: string) => void;
|
|
101
128
|
endOnBackground?: boolean;
|
|
102
129
|
navigationRef?: {
|
|
@@ -112,8 +139,20 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
|
|
|
112
139
|
launcherDelayMs?: number;
|
|
113
140
|
visibility?: LauncherVisibility;
|
|
114
141
|
}
|
|
115
|
-
export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
|
|
142
|
+
export declare function GraineProvider({ children, autoConnect, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
|
|
116
143
|
export declare function useGraineAgent(): GraineContextValue;
|
|
144
|
+
export declare function useGraineSurface(): {
|
|
145
|
+
surface: AgentSurface;
|
|
146
|
+
surfaces: AgentSurface[];
|
|
147
|
+
canSwitch: boolean;
|
|
148
|
+
setSurface: (surface: AgentSurface) => void;
|
|
149
|
+
};
|
|
150
|
+
export declare function useGraineVariables(): (vars: Record<string, unknown> | null) => void;
|
|
151
|
+
export declare function useGraineConnection(): {
|
|
152
|
+
quality: ConnectionQuality;
|
|
153
|
+
online: boolean | null;
|
|
154
|
+
network: "online" | "offline" | "unknown";
|
|
155
|
+
};
|
|
117
156
|
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
118
157
|
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
119
158
|
export declare function useGraineTap<T extends (...args: any[]) => any>(name: string, handler?: T, data?: Record<string, unknown>): (...args: Parameters<T>) => ReturnType<T> | undefined;
|
|
@@ -6,7 +6,12 @@ import { VoiceSession } from "../voice.js";
|
|
|
6
6
|
import { resolveWidgetImages, normaliseWidgetStyle, normaliseSelectAction, selectCall, fillSelectTemplate } from "../protocol.js";
|
|
7
7
|
import { GraineFab, GraineTouchCapture } from "./fab.js";
|
|
8
8
|
export { GraineFab, GraineTouchCapture } from "./fab.js";
|
|
9
|
+
export { useKeyboardLift, liftFor } from "./keyboard.js";
|
|
10
|
+
export { useDraggablePlacement } from "./placement.js";
|
|
11
|
+
export { clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX } from "./placement-math.js";
|
|
9
12
|
export { widgetSurface } from "../protocol.js";
|
|
13
|
+
export { resolveSurface } from "../protocol.js";
|
|
14
|
+
import { resolveSurface } from "../protocol.js";
|
|
10
15
|
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
|
|
11
16
|
export const PENDING_WIDGET_MS = 12000;
|
|
12
17
|
export const AWAITING_REPLY_MS = 12000;
|
|
@@ -24,7 +29,7 @@ export function useGraineClient() {
|
|
|
24
29
|
}
|
|
25
30
|
return client;
|
|
26
31
|
}
|
|
27
|
-
export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
|
|
32
|
+
export function GraineProvider({ children, autoConnect = true, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground = true, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
|
|
28
33
|
const clientRef = useRef(null);
|
|
29
34
|
if (!clientRef.current) {
|
|
30
35
|
clientRef.current =
|
|
@@ -57,6 +62,30 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
57
62
|
}, []);
|
|
58
63
|
const [appearance, setAppearance] = useState(null);
|
|
59
64
|
const [sessionReady, setSessionReady] = useState(false);
|
|
65
|
+
const [connectionQuality, setConnectionQuality] = useState("unknown");
|
|
66
|
+
const [network, setNetwork] = useState("unknown");
|
|
67
|
+
const [chosenSurface, setChosenSurface] = useState(null);
|
|
68
|
+
const surfacePolicy = useMemo(() => resolveSurface(appearance, chosenSurface ?? surfaceProp ?? null), [appearance, chosenSurface, surfaceProp]);
|
|
69
|
+
const surface = surfacePolicy.start;
|
|
70
|
+
const surfaceRef = useRef(surface);
|
|
71
|
+
surfaceRef.current = surface;
|
|
72
|
+
const surfaceChangeRef = useRef(onSurfaceChange);
|
|
73
|
+
surfaceChangeRef.current = onSurfaceChange;
|
|
74
|
+
const setSurface = useCallback((next) => {
|
|
75
|
+
if (next !== "voice" && next !== "chat")
|
|
76
|
+
return;
|
|
77
|
+
setChosenSurface((cur) => (cur === next ? cur : next));
|
|
78
|
+
}, []);
|
|
79
|
+
const announcedSurfaceRef = useRef(null);
|
|
80
|
+
useEffect(() => { client.setSurface(surface); }, [client, surface]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (announcedSurfaceRef.current === surface)
|
|
83
|
+
return;
|
|
84
|
+
const first = announcedSurfaceRef.current === null;
|
|
85
|
+
announcedSurfaceRef.current = surface;
|
|
86
|
+
if (!first)
|
|
87
|
+
surfaceChangeRef.current?.(surface);
|
|
88
|
+
}, [surface]);
|
|
60
89
|
const [currentScreen, setCurrentScreen] = useState(null);
|
|
61
90
|
const [launcherVisible, setLauncherVisible] = useState(false);
|
|
62
91
|
const [launcherInset, setLauncherInset] = useState({ right: 16, bottom: 20 });
|
|
@@ -162,6 +191,15 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
162
191
|
})),
|
|
163
192
|
client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
|
|
164
193
|
client.on("mic_denied", (p) => emitEvent({ type: "mic_denied", reason: String(p?.reason || "NotAllowedError") })),
|
|
194
|
+
client.on("quality", (q) => {
|
|
195
|
+
const quality = (q?.quality ?? "unknown");
|
|
196
|
+
setConnectionQuality(quality);
|
|
197
|
+
emitEvent({ type: "connection_quality", quality, rtt: q?.rtt, jitter: q?.jitter, loss: q?.loss });
|
|
198
|
+
}),
|
|
199
|
+
client.on("network", (n) => {
|
|
200
|
+
setNetwork(n?.online ? "online" : "offline");
|
|
201
|
+
emitEvent({ type: "network", online: !!n?.online });
|
|
202
|
+
}),
|
|
165
203
|
client.on("friction", (s) => emitEvent({ type: "friction_detected", name: String(s?.name ?? ""), screen: String(s?.screen ?? ""), detail: String(s?.detail ?? "") })),
|
|
166
204
|
client.on("muted", (m) => {
|
|
167
205
|
setMutedState(m);
|
|
@@ -212,7 +250,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
212
250
|
client.highlight(name, Number(args?.ttl_ms) || 6000);
|
|
213
251
|
return { status: "ok", message: `Highlighted ${name} for the customer.` };
|
|
214
252
|
}));
|
|
215
|
-
if (autoConnect) {
|
|
253
|
+
if (autoConnect && !(client.hasVoiceHost && surfaceRef.current !== "chat")) {
|
|
216
254
|
setConnecting(true);
|
|
217
255
|
client.connect().catch((err) => {
|
|
218
256
|
setConnecting(false);
|
|
@@ -225,6 +263,27 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
225
263
|
client.close();
|
|
226
264
|
};
|
|
227
265
|
}, [client, autoConnect]);
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const apply = () => {
|
|
268
|
+
if (!client.hasVoiceHost)
|
|
269
|
+
return;
|
|
270
|
+
if (surface === "chat") {
|
|
271
|
+
if (client.isConnected)
|
|
272
|
+
return;
|
|
273
|
+
setConnecting(true);
|
|
274
|
+
client.connect().catch((err) => {
|
|
275
|
+
setConnecting(false);
|
|
276
|
+
console.error("[Graine]", err?.message ?? err);
|
|
277
|
+
setError(err?.message ?? "Could not start the chat.");
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
client.close();
|
|
282
|
+
setConnecting(false);
|
|
283
|
+
};
|
|
284
|
+
apply();
|
|
285
|
+
return client.on("voice_host", apply);
|
|
286
|
+
}, [client, surface]);
|
|
228
287
|
useEffect(() => {
|
|
229
288
|
if (!endOnBackground)
|
|
230
289
|
return;
|
|
@@ -279,8 +338,16 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
279
338
|
onEvent,
|
|
280
339
|
track: (name, data) => client.track(name, data),
|
|
281
340
|
identify: (user) => client.identify(user),
|
|
341
|
+
surface,
|
|
342
|
+
surfaces: surfacePolicy.allowed,
|
|
343
|
+
canSwitch: surfacePolicy.canSwitch,
|
|
344
|
+
setSurface,
|
|
345
|
+
connectionQuality,
|
|
346
|
+
network,
|
|
347
|
+
setVariables: (vars) => client.setVariables(vars),
|
|
282
348
|
}), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
|
|
283
|
-
launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady
|
|
349
|
+
launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady,
|
|
350
|
+
surface, surfacePolicy, setSurface, connectionQuality, network]);
|
|
284
351
|
const wantsCapture = captureTaps ?? !!fab;
|
|
285
352
|
const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
|
|
286
353
|
return _jsx(Ctx.Provider, { value: value, children: body });
|
|
@@ -294,6 +361,22 @@ function useGraine() {
|
|
|
294
361
|
export function useGraineAgent() {
|
|
295
362
|
return useGraine();
|
|
296
363
|
}
|
|
364
|
+
export function useGraineSurface() {
|
|
365
|
+
const { surface, surfaces, canSwitch, setSurface } = useGraine();
|
|
366
|
+
return { surface, surfaces, canSwitch, setSurface };
|
|
367
|
+
}
|
|
368
|
+
export function useGraineVariables() {
|
|
369
|
+
const { setVariables } = useGraine();
|
|
370
|
+
return setVariables;
|
|
371
|
+
}
|
|
372
|
+
export function useGraineConnection() {
|
|
373
|
+
const { connectionQuality, network } = useGraine();
|
|
374
|
+
return {
|
|
375
|
+
quality: connectionQuality,
|
|
376
|
+
online: network === "unknown" ? null : network === "online",
|
|
377
|
+
network,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
297
380
|
export function useGraineEvents(handler) {
|
|
298
381
|
const { onEvent } = useGraine();
|
|
299
382
|
const ref = useRef(handler);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function liftFor(keyboardHeight: number, windowShrink: number): number;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Keyboard, Platform, useWindowDimensions } from "react-native";
|
|
3
|
+
import { liftFor } from "./keyboard-math.js";
|
|
4
|
+
export { liftFor };
|
|
5
|
+
export function useKeyboardLift() {
|
|
6
|
+
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
7
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
8
|
+
const tallestRef = useRef(windowHeight);
|
|
9
|
+
if (windowHeight > tallestRef.current)
|
|
10
|
+
tallestRef.current = windowHeight;
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const show = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
|
|
13
|
+
const hide = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
|
|
14
|
+
const onShow = (e) => setKeyboardHeight(Number(e?.endCoordinates?.height) || 0);
|
|
15
|
+
const onHide = () => setKeyboardHeight(0);
|
|
16
|
+
const a = Keyboard.addListener(show, onShow);
|
|
17
|
+
const b = Keyboard.addListener(hide, onHide);
|
|
18
|
+
return () => { a.remove(); b.remove(); };
|
|
19
|
+
}, []);
|
|
20
|
+
return liftFor(keyboardHeight, tallestRef.current - windowHeight);
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const DRAG_SLOP_PX = 8;
|
|
2
|
+
export interface PlacementBounds {
|
|
3
|
+
windowHeight: number;
|
|
4
|
+
baseBottom: number;
|
|
5
|
+
barHeight: number;
|
|
6
|
+
topMargin?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function clampOffset(offset: number, b: PlacementBounds): number;
|
|
9
|
+
export declare function offsetDuringDrag(startOffset: number, dy: number, b: PlacementBounds): number;
|
|
10
|
+
export declare function isTap(dx: number, dy: number): boolean;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const DRAG_SLOP_PX = 8;
|
|
2
|
+
export function clampOffset(offset, b) {
|
|
3
|
+
if (!Number.isFinite(offset))
|
|
4
|
+
return 0;
|
|
5
|
+
const top = b.topMargin ?? 48;
|
|
6
|
+
const height = b.barHeight > 0 ? b.barHeight : 64;
|
|
7
|
+
const maxOffset = Math.max(0, b.windowHeight - top - height - b.baseBottom);
|
|
8
|
+
return Math.round(Math.min(Math.max(offset, 0), maxOffset));
|
|
9
|
+
}
|
|
10
|
+
export function offsetDuringDrag(startOffset, dy, b) {
|
|
11
|
+
return clampOffset(startOffset - dy, b);
|
|
12
|
+
}
|
|
13
|
+
export function isTap(dx, dy) {
|
|
14
|
+
return Math.abs(dx) < DRAG_SLOP_PX && Math.abs(dy) < DRAG_SLOP_PX;
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type GestureResponderHandlers } from "react-native";
|
|
2
|
+
export interface DraggablePlacementOptions {
|
|
3
|
+
baseBottom: number;
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
initialOffset?: number;
|
|
6
|
+
onChange?: (offset: number) => void;
|
|
7
|
+
topMargin?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface DraggablePlacement {
|
|
10
|
+
offset: number;
|
|
11
|
+
panHandlers: GestureResponderHandlers;
|
|
12
|
+
onLayout: (e: any) => void;
|
|
13
|
+
dragging: boolean;
|
|
14
|
+
reset: () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function useDraggablePlacement(opts: DraggablePlacementOptions): DraggablePlacement;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { PanResponder, useWindowDimensions } from "react-native";
|
|
3
|
+
import { clampOffset, isTap, offsetDuringDrag } from "./placement-math.js";
|
|
4
|
+
export function useDraggablePlacement(opts) {
|
|
5
|
+
const { baseBottom, enabled = true, initialOffset, onChange, topMargin } = opts;
|
|
6
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
7
|
+
const [offset, setOffset] = useState(() => initialOffset ?? 0);
|
|
8
|
+
const [barHeight, setBarHeight] = useState(0);
|
|
9
|
+
const [dragging, setDragging] = useState(false);
|
|
10
|
+
const boundsRef = useRef({ windowHeight, baseBottom, barHeight, topMargin });
|
|
11
|
+
boundsRef.current = { windowHeight, baseBottom, barHeight, topMargin };
|
|
12
|
+
const offsetRef = useRef(offset);
|
|
13
|
+
offsetRef.current = offset;
|
|
14
|
+
const changeRef = useRef(onChange);
|
|
15
|
+
changeRef.current = onChange;
|
|
16
|
+
const startRef = useRef(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (initialOffset !== undefined)
|
|
19
|
+
setOffset(clampOffset(initialOffset, boundsRef.current));
|
|
20
|
+
}, [initialOffset]);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
setOffset((cur) => clampOffset(cur, boundsRef.current));
|
|
23
|
+
}, [windowHeight, barHeight, baseBottom]);
|
|
24
|
+
const responder = useMemo(() => PanResponder.create({
|
|
25
|
+
onMoveShouldSetPanResponder: (_e, g) => enabled && !isTap(g.dx, g.dy),
|
|
26
|
+
onMoveShouldSetPanResponderCapture: (_e, g) => enabled && !isTap(g.dx, g.dy),
|
|
27
|
+
onPanResponderGrant: () => {
|
|
28
|
+
startRef.current = offsetRef.current;
|
|
29
|
+
setDragging(true);
|
|
30
|
+
},
|
|
31
|
+
onPanResponderMove: (_e, g) => {
|
|
32
|
+
setOffset(offsetDuringDrag(startRef.current, g.dy, boundsRef.current));
|
|
33
|
+
},
|
|
34
|
+
onPanResponderRelease: (_e, g) => {
|
|
35
|
+
const next = offsetDuringDrag(startRef.current, g.dy, boundsRef.current);
|
|
36
|
+
setOffset(next);
|
|
37
|
+
setDragging(false);
|
|
38
|
+
if (next !== startRef.current)
|
|
39
|
+
changeRef.current?.(next);
|
|
40
|
+
},
|
|
41
|
+
onPanResponderTerminate: () => {
|
|
42
|
+
setOffset(startRef.current);
|
|
43
|
+
setDragging(false);
|
|
44
|
+
},
|
|
45
|
+
}), [enabled]);
|
|
46
|
+
const onLayout = useCallback((e) => {
|
|
47
|
+
const h = Number(e?.nativeEvent?.layout?.height) || 0;
|
|
48
|
+
if (h > 0)
|
|
49
|
+
setBarHeight(h);
|
|
50
|
+
}, []);
|
|
51
|
+
const reset = useCallback(() => {
|
|
52
|
+
setOffset(0);
|
|
53
|
+
changeRef.current?.(0);
|
|
54
|
+
}, []);
|
|
55
|
+
return {
|
|
56
|
+
offset: enabled ? offset : 0,
|
|
57
|
+
panHandlers: enabled ? responder.panHandlers : {},
|
|
58
|
+
onLayout,
|
|
59
|
+
dragging,
|
|
60
|
+
reset,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -17,7 +17,14 @@ export interface GraineAgentBarProps {
|
|
|
17
17
|
captionsOn?: boolean;
|
|
18
18
|
scheme?: "dark" | "light" | "auto";
|
|
19
19
|
theme?: Partial<GraineBarTheme>;
|
|
20
|
+
draggable?: boolean;
|
|
21
|
+
placement?: {
|
|
22
|
+
bottomOffset?: number;
|
|
23
|
+
} | null;
|
|
24
|
+
onPlacementChange?: (placement: {
|
|
25
|
+
bottomOffset: number;
|
|
26
|
+
}) => void;
|
|
20
27
|
}
|
|
21
|
-
export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, scheme, theme: themeProp, }: GraineAgentBarProps): React.JSX.Element | null;
|
|
28
|
+
export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, scheme, theme: themeProp, draggable, placement, onPlacementChange, }: GraineAgentBarProps): React.JSX.Element | null;
|
|
22
29
|
export declare const GraineLauncher: typeof GraineAgentBar;
|
|
23
30
|
export type GraineLauncherProps = GraineAgentBarProps;
|
package/dist/react-native/ui.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
import { Image,
|
|
3
|
+
import { Image, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
|
|
4
|
+
import { useKeyboardLift } from "./keyboard.js";
|
|
5
|
+
import { useDraggablePlacement } from "./placement.js";
|
|
4
6
|
import { useGraineAgent } from "./index.js";
|
|
5
7
|
import { GraineLoader } from "./loader.js";
|
|
6
8
|
import { resolveNativeAppearance } from "./appearance.js";
|
|
@@ -22,8 +24,9 @@ const LIGHT = {
|
|
|
22
24
|
onAccent: "#FFFFFF",
|
|
23
25
|
danger: "#D32F2F",
|
|
24
26
|
};
|
|
25
|
-
export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, scheme = "auto", theme: themeProp, }) {
|
|
26
|
-
const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, } = useGraineAgent();
|
|
27
|
+
export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, scheme = "auto", theme: themeProp, draggable = true, placement, onPlacementChange, }) {
|
|
28
|
+
const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, surface, canSwitch, setSurface, connectionQuality, network, } = useGraineAgent();
|
|
29
|
+
const isChat = surface === "chat";
|
|
27
30
|
const dashboard = resolveNativeAppearance(appearance, {
|
|
28
31
|
accent: "#318CE7",
|
|
29
32
|
heading: "Assistant",
|
|
@@ -36,7 +39,15 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
36
39
|
const base = (scheme === "auto" ? deviceScheme === "light" : scheme === "light") ? LIGHT : DARK;
|
|
37
40
|
return makeStyles(themeProp ? { ...base, ...themeProp } : base);
|
|
38
41
|
}, [scheme, deviceScheme, themeProp]);
|
|
39
|
-
const [expanded, setExpanded] = useState(
|
|
42
|
+
const [expanded, setExpanded] = useState(surface === "chat");
|
|
43
|
+
const keyboardLift = useKeyboardLift();
|
|
44
|
+
const baseBottom = launcherInset.bottom ?? bottomInset;
|
|
45
|
+
const drag = useDraggablePlacement({
|
|
46
|
+
baseBottom,
|
|
47
|
+
enabled: draggable,
|
|
48
|
+
initialOffset: placement?.bottomOffset,
|
|
49
|
+
onChange: (bottomOffset) => onPlacementChange?.({ bottomOffset }),
|
|
50
|
+
});
|
|
40
51
|
const [captions, setCaptions] = useState(captionsOn);
|
|
41
52
|
const [draft, setDraft] = useState("");
|
|
42
53
|
const scrollRef = useRef(null);
|
|
@@ -45,15 +56,19 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
45
56
|
const waitingMoment = "connecting";
|
|
46
57
|
const status = error
|
|
47
58
|
? "unavailable"
|
|
48
|
-
:
|
|
49
|
-
:
|
|
50
|
-
:
|
|
51
|
-
:
|
|
52
|
-
: "
|
|
59
|
+
: network === "offline" ? "no connection"
|
|
60
|
+
: connecting ? "connecting…"
|
|
61
|
+
: muted ? "muted"
|
|
62
|
+
: agentSpeaking || (last?.role === "agent" && last.live) ? (isChat ? "replying" : "speaking")
|
|
63
|
+
: connected && connectionQuality === "poor" ? "weak connection"
|
|
64
|
+
: connected ? (isChat ? "ready" : "listening")
|
|
65
|
+
: "offline";
|
|
53
66
|
useEffect(() => {
|
|
54
67
|
if (expanded)
|
|
55
68
|
requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
|
|
56
69
|
}, [messages, expanded]);
|
|
70
|
+
useEffect(() => { if (surface === "chat")
|
|
71
|
+
setExpanded(true); }, [surface]);
|
|
57
72
|
useEffect(() => {
|
|
58
73
|
if (!expanded && last?.role === "agent")
|
|
59
74
|
setExpanded(true);
|
|
@@ -67,17 +82,20 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
67
82
|
}, [draft, send]);
|
|
68
83
|
if (hidden || error || !launcherVisible)
|
|
69
84
|
return null;
|
|
70
|
-
return (_jsxs(View, { pointerEvents: "box-none", style: [
|
|
85
|
+
return (_jsxs(View, { pointerEvents: "box-none", ...drag.panHandlers, onLayout: drag.onLayout, style: [
|
|
71
86
|
styles.root,
|
|
72
87
|
{
|
|
73
|
-
bottom:
|
|
88
|
+
bottom: baseBottom + drag.offset + keyboardLift,
|
|
74
89
|
right: launcherInset.right ?? 12,
|
|
75
90
|
left: launcherInset.left ?? 12,
|
|
76
91
|
},
|
|
77
|
-
], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children:
|
|
92
|
+
], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: [styles.transcript, keyboardLift ? styles.transcriptLifted : null], contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: isChat
|
|
93
|
+
? "I can see what you\u2019re working on \u2014 type a question, or I\u2019ll step in if you get stuck."
|
|
94
|
+
: "I can see what you\u2019re working on \u2014 ask me anything, or I\u2019ll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
|
|
78
95
|
styles.bubble,
|
|
79
96
|
m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
|
|
80
|
-
], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(
|
|
97
|
+
], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(View, { children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: styles.placeholder.color, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && !isChat && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => { if (!drag.dragging)
|
|
98
|
+
setExpanded((v) => !v); }, style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), waiting ? (_jsx(GraineLoader, { moment: waitingMoment, size: 15 })) : (_jsx(Text, { style: styles.status, children: status }))] }), canSwitch ? (_jsx(Pressable, { onPress: () => setSurface(isChat ? "voice" : "chat"), hitSlop: 8, accessibilityRole: "button", accessibilityLabel: isChat ? "Switch to a call" : "Switch to chat", style: styles.round, children: _jsx(Text, { style: styles.glyph, children: isChat ? "📞" : "💬" }) })) : null, waiting ? null : (_jsxs(_Fragment, { children: [isChat ? null : (_jsxs(_Fragment, { children: [_jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && styles.glyphOnAccent], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && styles.glyphOnAccent], children: muted ? "🔇" : "🎙" }) })] })), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] }))] })] }));
|
|
81
99
|
}
|
|
82
100
|
export const GraineLauncher = GraineAgentBar;
|
|
83
101
|
const makeStyles = (t) => StyleSheet.create({
|
|
@@ -87,6 +105,7 @@ const makeStyles = (t) => StyleSheet.create({
|
|
|
87
105
|
marginBottom: 8, overflow: "hidden",
|
|
88
106
|
},
|
|
89
107
|
transcript: { maxHeight: 240 },
|
|
108
|
+
transcriptLifted: { maxHeight: 150 },
|
|
90
109
|
transcriptInner: { padding: 14, gap: 8 },
|
|
91
110
|
empty: { color: t.sub, fontSize: 14, lineHeight: 21 },
|
|
92
111
|
bubble: { maxWidth: "88%", paddingHorizontal: 12, paddingVertical: 9, borderRadius: 16 },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { type Caption } from "./index.js";
|
|
3
|
+
import type { ConnectionQuality } from "../protocol.js";
|
|
3
4
|
export interface GraineVoiceLauncherProps {
|
|
4
5
|
transport?: "ws" | "rtc";
|
|
5
6
|
onTransport?: (info: {
|
|
@@ -26,6 +27,9 @@ export interface GraineVoiceLauncherProps {
|
|
|
26
27
|
timing?: {
|
|
27
28
|
readyTimeoutMs?: number;
|
|
28
29
|
retryDelaysMs?: number[];
|
|
30
|
+
stallMs?: number;
|
|
31
|
+
bootMs?: number;
|
|
32
|
+
hardCapMs?: number;
|
|
29
33
|
endWatchdogMs?: number;
|
|
30
34
|
};
|
|
31
35
|
children?: (api: GraineVoiceApi) => React.ReactNode;
|
|
@@ -52,6 +56,9 @@ export interface GraineVoiceApi {
|
|
|
52
56
|
captionsOn: boolean;
|
|
53
57
|
setCaptionsOn: (on: boolean) => void;
|
|
54
58
|
clearCaptions: () => void;
|
|
59
|
+
quality: ConnectionQuality;
|
|
60
|
+
network: "online" | "offline" | "unknown";
|
|
61
|
+
reconnecting: boolean;
|
|
55
62
|
}
|
|
56
63
|
export declare const CAPTION_HISTORY = 12;
|
|
57
|
-
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
64
|
+
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active: activeProp, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
@@ -4,9 +4,14 @@ import { AppState, View } from "react-native";
|
|
|
4
4
|
import { useGraineAgent } from "./index.js";
|
|
5
5
|
import { requestMicrophonePermission } from "./permissions.js";
|
|
6
6
|
export const CAPTION_HISTORY = 12;
|
|
7
|
-
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
|
|
8
|
-
const { client, appearance } = useGraineAgent();
|
|
7
|
+
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active: activeProp = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
|
|
8
|
+
const { client, appearance, surface, surfaces } = useGraineAgent();
|
|
9
|
+
const active = activeProp && surface !== "chat";
|
|
9
10
|
const ref = useRef(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
client.setVoiceHost(true);
|
|
13
|
+
return () => client.setVoiceHost(false);
|
|
14
|
+
}, [client]);
|
|
10
15
|
const startedRef = useRef(false);
|
|
11
16
|
const activeRef = useRef(active);
|
|
12
17
|
activeRef.current = active;
|
|
@@ -40,9 +45,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
40
45
|
const onErrorRef = useRef(onError);
|
|
41
46
|
onErrorRef.current = onError;
|
|
42
47
|
const READY_TIMEOUT_MS = timing?.readyTimeoutMs ?? 15000;
|
|
48
|
+
const STALL_TIMEOUT_MS = timing?.stallMs ?? READY_TIMEOUT_MS;
|
|
49
|
+
const BOOT_TIMEOUT_MS = timing?.bootMs ?? READY_TIMEOUT_MS;
|
|
50
|
+
const HARD_CAP_MS = timing?.hardCapMs ?? 120000;
|
|
43
51
|
const RETRY_DELAYS_MS = timing?.retryDelaysMs ?? [1000, 2000, 4000, 8000, 15000, 15000];
|
|
44
52
|
const END_WATCHDOG_MS = timing?.endWatchdogMs ?? 4000;
|
|
45
53
|
const [connectivity, setConnectivity] = useState({ state: "loading", attempt: 0 });
|
|
54
|
+
const [quality, setQuality] = useState("unknown");
|
|
55
|
+
const [network, setNetwork] = useState("unknown");
|
|
56
|
+
const [reconnecting, setReconnecting] = useState(false);
|
|
46
57
|
const attemptRef = useRef(0);
|
|
47
58
|
const retryTimerRef = useRef(null);
|
|
48
59
|
const readyTimerRef = useRef(null);
|
|
@@ -84,13 +95,47 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
84
95
|
catch { }
|
|
85
96
|
}, delay);
|
|
86
97
|
}, []);
|
|
98
|
+
const progressRef = useRef(0);
|
|
99
|
+
const movedAtRef = useRef(0);
|
|
100
|
+
const loadStartedAtRef = useRef(0);
|
|
101
|
+
const downloadedRef = useRef(false);
|
|
102
|
+
const noteDownloaded = useCallback(() => {
|
|
103
|
+
downloadedRef.current = true;
|
|
104
|
+
movedAtRef.current = Date.now();
|
|
105
|
+
}, []);
|
|
106
|
+
const noteProgress = useCallback((p) => {
|
|
107
|
+
if (p <= progressRef.current)
|
|
108
|
+
return;
|
|
109
|
+
progressRef.current = p;
|
|
110
|
+
movedAtRef.current = Date.now();
|
|
111
|
+
if (p >= 1)
|
|
112
|
+
downloadedRef.current = true;
|
|
113
|
+
}, []);
|
|
87
114
|
const armReadyTimer = useCallback(() => {
|
|
88
115
|
clearTimer(readyTimerRef);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
116
|
+
progressRef.current = 0;
|
|
117
|
+
downloadedRef.current = false;
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
loadStartedAtRef.current = now;
|
|
120
|
+
movedAtRef.current = now;
|
|
121
|
+
const tick = () => {
|
|
122
|
+
const since = Date.now() - movedAtRef.current;
|
|
123
|
+
const total = Date.now() - loadStartedAtRef.current;
|
|
124
|
+
const limit = downloadedRef.current ? BOOT_TIMEOUT_MS : STALL_TIMEOUT_MS;
|
|
125
|
+
if (since >= limit) {
|
|
126
|
+
readyTimerRef.current = null;
|
|
127
|
+
scheduleRetry(downloadedRef.current ? "boot-timeout" : "stalled");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (total >= HARD_CAP_MS) {
|
|
131
|
+
readyTimerRef.current = null;
|
|
132
|
+
scheduleRetry("load-cap");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
readyTimerRef.current = setTimeout(tick, Math.max(250, Math.min(limit - since, HARD_CAP_MS - total)));
|
|
136
|
+
};
|
|
137
|
+
readyTimerRef.current = setTimeout(tick, Math.min(STALL_TIMEOUT_MS, 1000));
|
|
138
|
+
}, [scheduleRetry, STALL_TIMEOUT_MS, BOOT_TIMEOUT_MS, HARD_CAP_MS]);
|
|
94
139
|
const restartLoad = useCallback(() => {
|
|
95
140
|
clearTimer(retryTimerRef);
|
|
96
141
|
attemptRef.current = 0;
|
|
@@ -216,6 +261,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
216
261
|
}
|
|
217
262
|
return;
|
|
218
263
|
case "graine:ready":
|
|
264
|
+
post({
|
|
265
|
+
type: "graine:variables",
|
|
266
|
+
variables: client.getVariables(),
|
|
267
|
+
});
|
|
219
268
|
clearTimer(readyTimerRef);
|
|
220
269
|
clearTimer(retryTimerRef);
|
|
221
270
|
attemptRef.current = 0;
|
|
@@ -232,8 +281,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
232
281
|
}
|
|
233
282
|
return;
|
|
234
283
|
case "graine:call":
|
|
235
|
-
if (msg.connected)
|
|
284
|
+
if (msg.connected) {
|
|
236
285
|
endedNotifiedRef.current = false;
|
|
286
|
+
setReconnecting(false);
|
|
287
|
+
}
|
|
237
288
|
setConnected(!!msg.connected);
|
|
238
289
|
setConnecting(!!msg.connecting);
|
|
239
290
|
client.emit?.("call_state", { connected: !!msg.connected, connecting: !!msg.connecting });
|
|
@@ -269,12 +320,27 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
269
320
|
clearTimer(endWatchdogRef);
|
|
270
321
|
setConnected(false);
|
|
271
322
|
setConnecting(false);
|
|
323
|
+
setReconnecting(false);
|
|
324
|
+
setQuality(client.reportQuality(null));
|
|
272
325
|
connectedRef.current = false;
|
|
273
326
|
if (!endedNotifiedRef.current) {
|
|
274
327
|
endedNotifiedRef.current = true;
|
|
275
328
|
onEnded?.();
|
|
276
329
|
}
|
|
277
330
|
return;
|
|
331
|
+
case "graine:quality":
|
|
332
|
+
setQuality(client.reportQuality(msg.sample && typeof msg.sample === "object" ? msg.sample : null));
|
|
333
|
+
return;
|
|
334
|
+
case "graine:network":
|
|
335
|
+
setNetwork(msg.online ? "online" : "offline");
|
|
336
|
+
client.reportNetwork(!!msg.online);
|
|
337
|
+
setQuality(client.connectionQuality);
|
|
338
|
+
return;
|
|
339
|
+
case "graine:reconnecting":
|
|
340
|
+
setReconnecting(!!msg.active);
|
|
341
|
+
if (msg.active)
|
|
342
|
+
setConnecting(true);
|
|
343
|
+
return;
|
|
278
344
|
case "graine:mic-denied":
|
|
279
345
|
client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
|
|
280
346
|
onMicDenied?.(String(msg.reason || "denied"));
|
|
@@ -299,7 +365,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
299
365
|
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
|
|
300
366
|
const pendingStartRef = useRef(false);
|
|
301
367
|
const lastCaption = captions.length ? captions[captions.length - 1] : null;
|
|
302
|
-
const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
|
|
368
|
+
const phase = reconnecting || connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
|
|
303
369
|
: connecting || (!ready && !!voiceAgentId) ? "connecting"
|
|
304
370
|
: !connected ? "idle"
|
|
305
371
|
: lastCaption?.role === "agent" && lastCaption.live ? "speaking"
|
|
@@ -330,11 +396,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
330
396
|
end: useCallback(() => { post({ type: "graine:end-call" }); armEndWatchdog(); }, [post, armEndWatchdog]),
|
|
331
397
|
connectivity,
|
|
332
398
|
retry: restartLoad,
|
|
399
|
+
quality,
|
|
400
|
+
network,
|
|
401
|
+
reconnecting,
|
|
333
402
|
setMuted: useCallback((m) => {
|
|
334
403
|
setMutedState(m);
|
|
335
404
|
post({ type: "graine:set-muted", muted: m });
|
|
336
405
|
}, [post]),
|
|
337
406
|
};
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
if (!ready)
|
|
409
|
+
return;
|
|
410
|
+
return client.on("variables", (vars) => {
|
|
411
|
+
post({ type: "graine:variables", variables: vars });
|
|
412
|
+
});
|
|
413
|
+
}, [client, ready, post]);
|
|
338
414
|
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
339
415
|
const activeSeenRef = useRef(null);
|
|
340
416
|
useEffect(() => {
|
|
@@ -399,14 +475,14 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
399
475
|
return () => { if (pending)
|
|
400
476
|
clearTimeout(pending); sub.remove(); };
|
|
401
477
|
}, [post]);
|
|
402
|
-
if (!voiceAgentId)
|
|
478
|
+
if (!voiceAgentId || !surfaces.includes("voice"))
|
|
403
479
|
return _jsx(_Fragment, { children: children?.(api) });
|
|
404
480
|
const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
|
|
405
481
|
const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
|
|
406
482
|
`?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
|
|
407
|
-
`&b=${mountStampRef.current}` +
|
|
483
|
+
`&b=${client.config?.pageVersion ?? mountStampRef.current}` +
|
|
408
484
|
(mountedIdleRef.current ? "&idle=1" : "") +
|
|
409
485
|
`&pk=${encodeURIComponent(client.opts.publishableKey)}` +
|
|
410
486
|
(appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
|
|
411
|
-
return (_jsxs(_Fragment, { children: [_jsx(View, { style: { position: "absolute", width: 1, height: 1, opacity: 0, bottom: 0, left: 0 }, pointerEvents: "none", children: _jsx(WebView, { ref: ref, source: { uri: src }, originWhitelist: ["*"], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, mediaCapturePermissionGrantType: "grant", onMessage: onMessage, onLoadStart: armReadyTimer, onError: (e) => scheduleRetry(`load-error:${e?.nativeEvent?.code ?? ""}`), onHttpError: (e) => scheduleRetry(`http-${e?.nativeEvent?.statusCode ?? ""}`), onRenderProcessGone: () => scheduleRetry("render-process-gone"), style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
|
|
487
|
+
return (_jsxs(_Fragment, { children: [_jsx(View, { style: { position: "absolute", width: 1, height: 1, opacity: 0, bottom: 0, left: 0 }, pointerEvents: "none", children: _jsx(WebView, { ref: ref, source: { uri: src }, originWhitelist: ["*"], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, mediaCapturePermissionGrantType: "grant", onMessage: onMessage, onLoadStart: armReadyTimer, onLoadProgress: (e) => noteProgress(Number(e?.nativeEvent?.progress) || 0), onLoadEnd: noteDownloaded, onError: (e) => scheduleRetry(`load-error:${e?.nativeEvent?.code ?? ""}`), onHttpError: (e) => scheduleRetry(`http-${e?.nativeEvent?.statusCode ?? ""}`), onRenderProcessGone: () => scheduleRetry("render-process-gone"), style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
|
|
412
488
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graineai/inapp-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Graine in-app agent for React Native
|
|
5
|
+
"description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
8
8
|
"main": "dist/index.js",
|