@graineai/inapp-react-native 0.34.0 → 0.36.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 +2 -2
- package/dist/client.d.ts +12 -1
- package/dist/client.js +79 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/protocol.d.ts +22 -0
- package/dist/protocol.js +69 -0
- package/dist/react-native/index.d.ts +25 -1
- package/dist/react-native/index.js +30 -1
- 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 +26 -12
- package/dist/react-native/voice-launcher.d.ts +7 -0
- package/dist/react-native/voice-launcher.js +82 -10
- package/package.json +1 -1
package/INTEGRATION.md
CHANGED
|
@@ -815,9 +815,9 @@ is not happening, and the table says which.
|
|
|
815
815
|
| Wait | What it is | When it is paid | Typical |
|
|
816
816
|
|---|---|---|---|
|
|
817
817
|
| Page load | The voice engine is a page in a WebView | **Before the tap** — `GraineFab` mounts the engine on open; a custom bar mounts `GraineVoiceLauncher` at launch with `active={false}` and flips it on open (0.27.5) | ~4s, hidden |
|
|
818
|
-
| Mint + REGISTER | A 120-second SIP credential, then registration on the SBC | **Before the tap**, re-warmed as it nears expiry | ~
|
|
818
|
+
| Mint + REGISTER | A 120-second SIP credential, then registration on the SBC | **Before the tap**, re-warmed as it nears expiry and whenever the app comes back to the foreground (0.36.0) | ~1s, hidden |
|
|
819
819
|
| Microphone | The OS permission prompt, first time only | At the tap | 0 once granted |
|
|
820
|
-
| INVITE → answer | The call itself | At the tap | ~0.
|
|
820
|
+
| INVITE → answer | The call itself, on a network path prepared before the tap | At the tap | ~0.2s |
|
|
821
821
|
|
|
822
822
|
**The INVITE is bounded, not left to the browser.** WebRTC gathers network
|
|
823
823
|
candidates before dialling, and Chrome does not report gathering complete
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext, type ScreenField, type AgentSurface } 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);
|
package/dist/client.js
CHANGED
|
@@ -1,9 +1,66 @@
|
|
|
1
|
-
import { contextToken, 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,6 +83,9 @@ 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;
|
|
@@ -161,6 +221,7 @@ export class GraineInAppClient {
|
|
|
161
221
|
body: JSON.stringify({
|
|
162
222
|
publishableKey: this.opts.publishableKey,
|
|
163
223
|
mode: this.socketIsVoice ? "voice" : "text",
|
|
224
|
+
metadata: variablesForTicket(this.allVariables()),
|
|
164
225
|
}),
|
|
165
226
|
});
|
|
166
227
|
if (!res.ok)
|
|
@@ -200,7 +261,7 @@ export class GraineInAppClient {
|
|
|
200
261
|
url = `${host}/chat/v1/${encodeURIComponent(agentId)}?${mode}`;
|
|
201
262
|
this.transport = "direct";
|
|
202
263
|
}
|
|
203
|
-
const token = contextToken(
|
|
264
|
+
const token = contextToken(this.allVariables(), this.socketIsVoice ? "webcall" : "text_chat");
|
|
204
265
|
if (token)
|
|
205
266
|
url += `${url.includes("?") ? "&" : "?"}context_token=${encodeURIComponent(token)}`;
|
|
206
267
|
const WS = this.opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
@@ -227,11 +288,7 @@ export class GraineInAppClient {
|
|
|
227
288
|
event: "start",
|
|
228
289
|
meta_data: {
|
|
229
290
|
client: "graine-inapp-sdk",
|
|
230
|
-
context_data:
|
|
231
|
-
...(this.config.variables ?? {}),
|
|
232
|
-
...(this.opts.variables ?? {}),
|
|
233
|
-
...this.identity,
|
|
234
|
-
},
|
|
291
|
+
context_data: this.allVariables(),
|
|
235
292
|
},
|
|
236
293
|
});
|
|
237
294
|
this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
|
|
@@ -260,9 +317,20 @@ export class GraineInAppClient {
|
|
|
260
317
|
return;
|
|
261
318
|
}
|
|
262
319
|
switch (msg.type) {
|
|
263
|
-
case "text":
|
|
264
|
-
|
|
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
|
+
}
|
|
265
332
|
break;
|
|
333
|
+
}
|
|
266
334
|
case "widget":
|
|
267
335
|
this.emit("widget", msg.widget);
|
|
268
336
|
break;
|
|
@@ -554,6 +622,7 @@ export class GraineInAppClient {
|
|
|
554
622
|
identify(user) {
|
|
555
623
|
if (!user) {
|
|
556
624
|
this.identity = {};
|
|
625
|
+
this.emit("variables", this.allVariables());
|
|
557
626
|
return;
|
|
558
627
|
}
|
|
559
628
|
const flat = {};
|
|
@@ -563,6 +632,7 @@ export class GraineInAppClient {
|
|
|
563
632
|
flat[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
564
633
|
}
|
|
565
634
|
this.identity = maskDeep(flat);
|
|
635
|
+
this.emit("variables", this.allVariables());
|
|
566
636
|
this.scheduleScreen();
|
|
567
637
|
}
|
|
568
638
|
noteInteraction() {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
|
|
|
9
9
|
export { contextToken } from "./protocol.js";
|
|
10
10
|
export { resolveSurface } from "./protocol.js";
|
|
11
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";
|
|
12
14
|
export type { WidgetSelect } from "./protocol.js";
|
|
13
15
|
export type { WidgetSelectAction } from "./protocol.js";
|
|
14
16
|
export { GraineWidget } from "./react-native/widget.js";
|
|
@@ -21,7 +23,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type Fri
|
|
|
21
23
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
|
|
22
24
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
|
|
23
25
|
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
24
|
-
export { GraineProvider, useGraineAgent, useGraineSurface, 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";
|
|
25
27
|
export { requestMicrophonePermission } from "./react-native/permissions.js";
|
|
26
28
|
export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, type GraineFabProps, type GraineTouchCaptureProps, } from "./react-native/fab.js";
|
|
27
29
|
export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall,
|
|
|
5
5
|
export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
|
|
6
6
|
export { contextToken } from "./protocol.js";
|
|
7
7
|
export { resolveSurface } from "./protocol.js";
|
|
8
|
+
export { classifyQuality, variablesForTicket, TICKET_VARIABLE_LIMITS, readAgentChunk, STREAM_START, STREAM_END } from "./protocol.js";
|
|
8
9
|
export { GraineWidget } from "./react-native/widget.js";
|
|
9
10
|
export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
|
|
10
11
|
export { PENDING_WIDGET_MS, AWAITING_REPLY_MS, MIN_LOADER_MS } from "./react-native/index.js";
|
|
@@ -13,7 +14,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "
|
|
|
13
14
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
|
|
14
15
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
|
|
15
16
|
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
16
|
-
export { GraineProvider, useGraineAgent, useGraineSurface, 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";
|
|
17
18
|
export { requestMicrophonePermission } from "./react-native/permissions.js";
|
|
18
19
|
export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, } from "./react-native/fab.js";
|
|
19
20
|
export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -187,3 +187,25 @@ export interface SurfacePolicy {
|
|
|
187
187
|
canSwitch: boolean;
|
|
188
188
|
}
|
|
189
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
|
@@ -320,3 +320,72 @@ export function resolveSurface(appearance, prefer) {
|
|
|
320
320
|
: allowed[0];
|
|
321
321
|
return { allowed, start, canSwitch: allowed.length > 1 };
|
|
322
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,11 +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";
|
|
10
15
|
export { resolveSurface } from "../protocol.js";
|
|
11
16
|
export type { AgentSurface, SurfacePolicy } from "../protocol.js";
|
|
12
|
-
import { type AgentSurface } from "../protocol.js";
|
|
17
|
+
import { type AgentSurface, type ConnectionQuality } from "../protocol.js";
|
|
18
|
+
export type { ConnectionQuality, QualitySample } from "../protocol.js";
|
|
13
19
|
import type { FrictionConfig } from "../friction.js";
|
|
14
20
|
import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
|
|
15
21
|
interface GraineContextValue {
|
|
@@ -38,6 +44,9 @@ interface GraineContextValue {
|
|
|
38
44
|
surfaces: AgentSurface[];
|
|
39
45
|
canSwitch: boolean;
|
|
40
46
|
setSurface: (surface: AgentSurface) => void;
|
|
47
|
+
connectionQuality: ConnectionQuality;
|
|
48
|
+
network: "online" | "offline" | "unknown";
|
|
49
|
+
setVariables: (vars: Record<string, unknown> | null) => void;
|
|
41
50
|
}
|
|
42
51
|
export type GraineEvent = {
|
|
43
52
|
type: "conversation_started";
|
|
@@ -75,6 +84,15 @@ export type GraineEvent = {
|
|
|
75
84
|
name: string;
|
|
76
85
|
screen: string;
|
|
77
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;
|
|
78
96
|
};
|
|
79
97
|
export declare const PENDING_WIDGET_MS = 12000;
|
|
80
98
|
export declare const AWAITING_REPLY_MS = 12000;
|
|
@@ -129,6 +147,12 @@ export declare function useGraineSurface(): {
|
|
|
129
147
|
canSwitch: boolean;
|
|
130
148
|
setSurface: (surface: AgentSurface) => void;
|
|
131
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
|
+
};
|
|
132
156
|
export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
|
|
133
157
|
export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
|
|
134
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,6 +6,9 @@ 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";
|
|
10
13
|
export { resolveSurface } from "../protocol.js";
|
|
11
14
|
import { resolveSurface } from "../protocol.js";
|
|
@@ -59,6 +62,8 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
|
|
|
59
62
|
}, []);
|
|
60
63
|
const [appearance, setAppearance] = useState(null);
|
|
61
64
|
const [sessionReady, setSessionReady] = useState(false);
|
|
65
|
+
const [connectionQuality, setConnectionQuality] = useState("unknown");
|
|
66
|
+
const [network, setNetwork] = useState("unknown");
|
|
62
67
|
const [chosenSurface, setChosenSurface] = useState(null);
|
|
63
68
|
const surfacePolicy = useMemo(() => resolveSurface(appearance, chosenSurface ?? surfaceProp ?? null), [appearance, chosenSurface, surfaceProp]);
|
|
64
69
|
const surface = surfacePolicy.start;
|
|
@@ -186,6 +191,15 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
|
|
|
186
191
|
})),
|
|
187
192
|
client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
|
|
188
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
|
+
}),
|
|
189
203
|
client.on("friction", (s) => emitEvent({ type: "friction_detected", name: String(s?.name ?? ""), screen: String(s?.screen ?? ""), detail: String(s?.detail ?? "") })),
|
|
190
204
|
client.on("muted", (m) => {
|
|
191
205
|
setMutedState(m);
|
|
@@ -328,9 +342,12 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
|
|
|
328
342
|
surfaces: surfacePolicy.allowed,
|
|
329
343
|
canSwitch: surfacePolicy.canSwitch,
|
|
330
344
|
setSurface,
|
|
345
|
+
connectionQuality,
|
|
346
|
+
network,
|
|
347
|
+
setVariables: (vars) => client.setVariables(vars),
|
|
331
348
|
}), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
|
|
332
349
|
launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady,
|
|
333
|
-
surface, surfacePolicy, setSurface]);
|
|
350
|
+
surface, surfacePolicy, setSurface, connectionQuality, network]);
|
|
334
351
|
const wantsCapture = captureTaps ?? !!fab;
|
|
335
352
|
const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
|
|
336
353
|
return _jsx(Ctx.Provider, { value: value, children: body });
|
|
@@ -348,6 +365,18 @@ export function useGraineSurface() {
|
|
|
348
365
|
const { surface, surfaces, canSwitch, setSurface } = useGraine();
|
|
349
366
|
return { surface, surfaces, canSwitch, setSurface };
|
|
350
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
|
+
}
|
|
351
380
|
export function useGraineEvents(handler) {
|
|
352
381
|
const { onEvent } = useGraine();
|
|
353
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,8 @@ 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, surface, canSwitch, setSurface, } = 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();
|
|
27
29
|
const isChat = surface === "chat";
|
|
28
30
|
const dashboard = resolveNativeAppearance(appearance, {
|
|
29
31
|
accent: "#318CE7",
|
|
@@ -38,6 +40,14 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
38
40
|
return makeStyles(themeProp ? { ...base, ...themeProp } : base);
|
|
39
41
|
}, [scheme, deviceScheme, themeProp]);
|
|
40
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
|
+
});
|
|
41
51
|
const [captions, setCaptions] = useState(captionsOn);
|
|
42
52
|
const [draft, setDraft] = useState("");
|
|
43
53
|
const scrollRef = useRef(null);
|
|
@@ -46,11 +56,13 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
46
56
|
const waitingMoment = "connecting";
|
|
47
57
|
const status = error
|
|
48
58
|
? "unavailable"
|
|
49
|
-
:
|
|
50
|
-
:
|
|
51
|
-
:
|
|
52
|
-
:
|
|
53
|
-
: "
|
|
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";
|
|
54
66
|
useEffect(() => {
|
|
55
67
|
if (expanded)
|
|
56
68
|
requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
|
|
@@ -70,19 +82,20 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
70
82
|
}, [draft, send]);
|
|
71
83
|
if (hidden || error || !launcherVisible)
|
|
72
84
|
return null;
|
|
73
|
-
return (_jsxs(View, { pointerEvents: "box-none", style: [
|
|
85
|
+
return (_jsxs(View, { pointerEvents: "box-none", ...drag.panHandlers, onLayout: drag.onLayout, style: [
|
|
74
86
|
styles.root,
|
|
75
87
|
{
|
|
76
|
-
bottom:
|
|
88
|
+
bottom: baseBottom + drag.offset + keyboardLift,
|
|
77
89
|
right: launcherInset.right ?? 12,
|
|
78
90
|
left: launcherInset.left ?? 12,
|
|
79
91
|
},
|
|
80
|
-
], 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: isChat
|
|
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
|
|
81
93
|
? "I can see what you\u2019re working on \u2014 type a question, or I\u2019ll step in if you get stuck."
|
|
82
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: [
|
|
83
95
|
styles.bubble,
|
|
84
96
|
m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
|
|
85
|
-
], 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 ? "⌄" : "⌃" }) })] }))] })] }));
|
|
86
99
|
}
|
|
87
100
|
export const GraineLauncher = GraineAgentBar;
|
|
88
101
|
const makeStyles = (t) => StyleSheet.create({
|
|
@@ -92,6 +105,7 @@ const makeStyles = (t) => StyleSheet.create({
|
|
|
92
105
|
marginBottom: 8, overflow: "hidden",
|
|
93
106
|
},
|
|
94
107
|
transcript: { maxHeight: 240 },
|
|
108
|
+
transcriptLifted: { maxHeight: 150 },
|
|
95
109
|
transcriptInner: { padding: 14, gap: 8 },
|
|
96
110
|
empty: { color: t.sub, fontSize: 14, lineHeight: 21 },
|
|
97
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
64
|
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active: activeProp, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
@@ -21,6 +21,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
21
21
|
const [connecting, setConnecting] = useState(false);
|
|
22
22
|
const [muted, setMutedState] = useState(false);
|
|
23
23
|
const [ready, setReady] = useState(false);
|
|
24
|
+
const readyRef = useRef(false);
|
|
25
|
+
readyRef.current = ready;
|
|
24
26
|
const [captions, setCaptions] = useState([]);
|
|
25
27
|
const [awaitingAgent, setAwaitingAgent] = useState(false);
|
|
26
28
|
const [captionsOn, setCaptionsOn] = useState(true);
|
|
@@ -45,9 +47,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
45
47
|
const onErrorRef = useRef(onError);
|
|
46
48
|
onErrorRef.current = onError;
|
|
47
49
|
const READY_TIMEOUT_MS = timing?.readyTimeoutMs ?? 15000;
|
|
50
|
+
const STALL_TIMEOUT_MS = timing?.stallMs ?? READY_TIMEOUT_MS;
|
|
51
|
+
const BOOT_TIMEOUT_MS = timing?.bootMs ?? READY_TIMEOUT_MS;
|
|
52
|
+
const HARD_CAP_MS = timing?.hardCapMs ?? 120000;
|
|
48
53
|
const RETRY_DELAYS_MS = timing?.retryDelaysMs ?? [1000, 2000, 4000, 8000, 15000, 15000];
|
|
49
54
|
const END_WATCHDOG_MS = timing?.endWatchdogMs ?? 4000;
|
|
50
55
|
const [connectivity, setConnectivity] = useState({ state: "loading", attempt: 0 });
|
|
56
|
+
const [quality, setQuality] = useState("unknown");
|
|
57
|
+
const [network, setNetwork] = useState("unknown");
|
|
58
|
+
const [reconnecting, setReconnecting] = useState(false);
|
|
51
59
|
const attemptRef = useRef(0);
|
|
52
60
|
const retryTimerRef = useRef(null);
|
|
53
61
|
const readyTimerRef = useRef(null);
|
|
@@ -89,13 +97,48 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
89
97
|
catch { }
|
|
90
98
|
}, delay);
|
|
91
99
|
}, []);
|
|
100
|
+
const progressRef = useRef(0);
|
|
101
|
+
const movedAtRef = useRef(0);
|
|
102
|
+
const loadStartedAtRef = useRef(0);
|
|
103
|
+
const downloadedRef = useRef(false);
|
|
104
|
+
const noteDownloaded = useCallback(() => {
|
|
105
|
+
downloadedRef.current = true;
|
|
106
|
+
movedAtRef.current = Date.now();
|
|
107
|
+
}, []);
|
|
108
|
+
const noteProgress = useCallback((p) => {
|
|
109
|
+
if (p <= progressRef.current)
|
|
110
|
+
return;
|
|
111
|
+
progressRef.current = p;
|
|
112
|
+
movedAtRef.current = Date.now();
|
|
113
|
+
if (p >= 1)
|
|
114
|
+
downloadedRef.current = true;
|
|
115
|
+
}, []);
|
|
92
116
|
const armReadyTimer = useCallback(() => {
|
|
93
117
|
clearTimer(readyTimerRef);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
progressRef.current = 0;
|
|
119
|
+
downloadedRef.current = false;
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
loadStartedAtRef.current = now;
|
|
122
|
+
movedAtRef.current = now;
|
|
123
|
+
const tick = () => {
|
|
124
|
+
const since = Date.now() - movedAtRef.current;
|
|
125
|
+
const total = Date.now() - loadStartedAtRef.current;
|
|
126
|
+
const limit = downloadedRef.current ? BOOT_TIMEOUT_MS : STALL_TIMEOUT_MS;
|
|
127
|
+
if (since >= limit) {
|
|
128
|
+
readyTimerRef.current = null;
|
|
129
|
+
scheduleRetry(downloadedRef.current ? "boot-timeout" : "stalled");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (total >= HARD_CAP_MS) {
|
|
133
|
+
readyTimerRef.current = null;
|
|
134
|
+
scheduleRetry("load-cap");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const floor = Math.min(250, limit / 4);
|
|
138
|
+
readyTimerRef.current = setTimeout(tick, Math.max(floor, Math.min(limit - since, HARD_CAP_MS - total)));
|
|
139
|
+
};
|
|
140
|
+
readyTimerRef.current = setTimeout(tick, Math.min(STALL_TIMEOUT_MS, 1000));
|
|
141
|
+
}, [scheduleRetry, STALL_TIMEOUT_MS, BOOT_TIMEOUT_MS, HARD_CAP_MS]);
|
|
99
142
|
const restartLoad = useCallback(() => {
|
|
100
143
|
clearTimer(retryTimerRef);
|
|
101
144
|
attemptRef.current = 0;
|
|
@@ -223,7 +266,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
223
266
|
case "graine:ready":
|
|
224
267
|
post({
|
|
225
268
|
type: "graine:variables",
|
|
226
|
-
variables:
|
|
269
|
+
variables: client.getVariables(),
|
|
227
270
|
});
|
|
228
271
|
clearTimer(readyTimerRef);
|
|
229
272
|
clearTimer(retryTimerRef);
|
|
@@ -241,8 +284,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
241
284
|
}
|
|
242
285
|
return;
|
|
243
286
|
case "graine:call":
|
|
244
|
-
if (msg.connected)
|
|
287
|
+
if (msg.connected) {
|
|
245
288
|
endedNotifiedRef.current = false;
|
|
289
|
+
setReconnecting(false);
|
|
290
|
+
}
|
|
246
291
|
setConnected(!!msg.connected);
|
|
247
292
|
setConnecting(!!msg.connecting);
|
|
248
293
|
client.emit?.("call_state", { connected: !!msg.connected, connecting: !!msg.connecting });
|
|
@@ -278,12 +323,27 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
278
323
|
clearTimer(endWatchdogRef);
|
|
279
324
|
setConnected(false);
|
|
280
325
|
setConnecting(false);
|
|
326
|
+
setReconnecting(false);
|
|
327
|
+
setQuality(client.reportQuality(null));
|
|
281
328
|
connectedRef.current = false;
|
|
282
329
|
if (!endedNotifiedRef.current) {
|
|
283
330
|
endedNotifiedRef.current = true;
|
|
284
331
|
onEnded?.();
|
|
285
332
|
}
|
|
286
333
|
return;
|
|
334
|
+
case "graine:quality":
|
|
335
|
+
setQuality(client.reportQuality(msg.sample && typeof msg.sample === "object" ? msg.sample : null));
|
|
336
|
+
return;
|
|
337
|
+
case "graine:network":
|
|
338
|
+
setNetwork(msg.online ? "online" : "offline");
|
|
339
|
+
client.reportNetwork(!!msg.online);
|
|
340
|
+
setQuality(client.connectionQuality);
|
|
341
|
+
return;
|
|
342
|
+
case "graine:reconnecting":
|
|
343
|
+
setReconnecting(!!msg.active);
|
|
344
|
+
if (msg.active)
|
|
345
|
+
setConnecting(true);
|
|
346
|
+
return;
|
|
287
347
|
case "graine:mic-denied":
|
|
288
348
|
client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
|
|
289
349
|
onMicDenied?.(String(msg.reason || "denied"));
|
|
@@ -308,7 +368,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
308
368
|
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
|
|
309
369
|
const pendingStartRef = useRef(false);
|
|
310
370
|
const lastCaption = captions.length ? captions[captions.length - 1] : null;
|
|
311
|
-
const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
|
|
371
|
+
const phase = reconnecting || connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
|
|
312
372
|
: connecting || (!ready && !!voiceAgentId) ? "connecting"
|
|
313
373
|
: !connected ? "idle"
|
|
314
374
|
: lastCaption?.role === "agent" && lastCaption.live ? "speaking"
|
|
@@ -339,11 +399,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
339
399
|
end: useCallback(() => { post({ type: "graine:end-call" }); armEndWatchdog(); }, [post, armEndWatchdog]),
|
|
340
400
|
connectivity,
|
|
341
401
|
retry: restartLoad,
|
|
402
|
+
quality,
|
|
403
|
+
network,
|
|
404
|
+
reconnecting,
|
|
342
405
|
setMuted: useCallback((m) => {
|
|
343
406
|
setMutedState(m);
|
|
344
407
|
post({ type: "graine:set-muted", muted: m });
|
|
345
408
|
}, [post]),
|
|
346
409
|
};
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
if (!ready)
|
|
412
|
+
return;
|
|
413
|
+
return client.on("variables", (vars) => {
|
|
414
|
+
post({ type: "graine:variables", variables: vars });
|
|
415
|
+
});
|
|
416
|
+
}, [client, ready, post]);
|
|
347
417
|
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
348
418
|
const activeSeenRef = useRef(null);
|
|
349
419
|
useEffect(() => {
|
|
@@ -395,6 +465,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
395
465
|
pending = null;
|
|
396
466
|
}
|
|
397
467
|
pushContext();
|
|
468
|
+
if (readyRef.current && !startedRef.current && !connectedRef.current)
|
|
469
|
+
post({ type: "graine:warm" });
|
|
398
470
|
return;
|
|
399
471
|
}
|
|
400
472
|
if (next === "background" && wasActive && !pending) {
|
|
@@ -413,9 +485,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
413
485
|
const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
|
|
414
486
|
const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
|
|
415
487
|
`?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
|
|
416
|
-
`&b=${mountStampRef.current}` +
|
|
488
|
+
`&b=${client.config?.pageVersion ?? mountStampRef.current}` +
|
|
417
489
|
(mountedIdleRef.current ? "&idle=1" : "") +
|
|
418
490
|
`&pk=${encodeURIComponent(client.opts.publishableKey)}` +
|
|
419
491
|
(appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
|
|
420
|
-
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)] }));
|
|
492
|
+
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)] }));
|
|
421
493
|
}
|
package/package.json
CHANGED