@graineai/inapp-react-native 0.34.0 → 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 +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 +77 -10
- package/package.json +1 -1
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;
|
|
@@ -45,9 +45,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
45
45
|
const onErrorRef = useRef(onError);
|
|
46
46
|
onErrorRef.current = onError;
|
|
47
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;
|
|
48
51
|
const RETRY_DELAYS_MS = timing?.retryDelaysMs ?? [1000, 2000, 4000, 8000, 15000, 15000];
|
|
49
52
|
const END_WATCHDOG_MS = timing?.endWatchdogMs ?? 4000;
|
|
50
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);
|
|
51
57
|
const attemptRef = useRef(0);
|
|
52
58
|
const retryTimerRef = useRef(null);
|
|
53
59
|
const readyTimerRef = useRef(null);
|
|
@@ -89,13 +95,47 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
89
95
|
catch { }
|
|
90
96
|
}, delay);
|
|
91
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
|
+
}, []);
|
|
92
114
|
const armReadyTimer = useCallback(() => {
|
|
93
115
|
clearTimer(readyTimerRef);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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]);
|
|
99
139
|
const restartLoad = useCallback(() => {
|
|
100
140
|
clearTimer(retryTimerRef);
|
|
101
141
|
attemptRef.current = 0;
|
|
@@ -223,7 +263,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
223
263
|
case "graine:ready":
|
|
224
264
|
post({
|
|
225
265
|
type: "graine:variables",
|
|
226
|
-
variables:
|
|
266
|
+
variables: client.getVariables(),
|
|
227
267
|
});
|
|
228
268
|
clearTimer(readyTimerRef);
|
|
229
269
|
clearTimer(retryTimerRef);
|
|
@@ -241,8 +281,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
241
281
|
}
|
|
242
282
|
return;
|
|
243
283
|
case "graine:call":
|
|
244
|
-
if (msg.connected)
|
|
284
|
+
if (msg.connected) {
|
|
245
285
|
endedNotifiedRef.current = false;
|
|
286
|
+
setReconnecting(false);
|
|
287
|
+
}
|
|
246
288
|
setConnected(!!msg.connected);
|
|
247
289
|
setConnecting(!!msg.connecting);
|
|
248
290
|
client.emit?.("call_state", { connected: !!msg.connected, connecting: !!msg.connecting });
|
|
@@ -278,12 +320,27 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
278
320
|
clearTimer(endWatchdogRef);
|
|
279
321
|
setConnected(false);
|
|
280
322
|
setConnecting(false);
|
|
323
|
+
setReconnecting(false);
|
|
324
|
+
setQuality(client.reportQuality(null));
|
|
281
325
|
connectedRef.current = false;
|
|
282
326
|
if (!endedNotifiedRef.current) {
|
|
283
327
|
endedNotifiedRef.current = true;
|
|
284
328
|
onEnded?.();
|
|
285
329
|
}
|
|
286
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;
|
|
287
344
|
case "graine:mic-denied":
|
|
288
345
|
client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
|
|
289
346
|
onMicDenied?.(String(msg.reason || "denied"));
|
|
@@ -308,7 +365,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
308
365
|
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
|
|
309
366
|
const pendingStartRef = useRef(false);
|
|
310
367
|
const lastCaption = captions.length ? captions[captions.length - 1] : null;
|
|
311
|
-
const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
|
|
368
|
+
const phase = reconnecting || connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
|
|
312
369
|
: connecting || (!ready && !!voiceAgentId) ? "connecting"
|
|
313
370
|
: !connected ? "idle"
|
|
314
371
|
: lastCaption?.role === "agent" && lastCaption.live ? "speaking"
|
|
@@ -339,11 +396,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
339
396
|
end: useCallback(() => { post({ type: "graine:end-call" }); armEndWatchdog(); }, [post, armEndWatchdog]),
|
|
340
397
|
connectivity,
|
|
341
398
|
retry: restartLoad,
|
|
399
|
+
quality,
|
|
400
|
+
network,
|
|
401
|
+
reconnecting,
|
|
342
402
|
setMuted: useCallback((m) => {
|
|
343
403
|
setMutedState(m);
|
|
344
404
|
post({ type: "graine:set-muted", muted: m });
|
|
345
405
|
}, [post]),
|
|
346
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]);
|
|
347
414
|
useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
|
|
348
415
|
const activeSeenRef = useRef(null);
|
|
349
416
|
useEffect(() => {
|
|
@@ -413,9 +480,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
413
480
|
const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
|
|
414
481
|
const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
|
|
415
482
|
`?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
|
|
416
|
-
`&b=${mountStampRef.current}` +
|
|
483
|
+
`&b=${client.config?.pageVersion ?? mountStampRef.current}` +
|
|
417
484
|
(mountedIdleRef.current ? "&idle=1" : "") +
|
|
418
485
|
`&pk=${encodeURIComponent(client.opts.publishableKey)}` +
|
|
419
486
|
(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)] }));
|
|
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)] }));
|
|
421
488
|
}
|
package/package.json
CHANGED