expo-openclaw-chat 0.2.1 → 0.2.4
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/package.json +14 -3
- package/src/chat/engine.ts +10 -1
- package/src/core/client.ts +66 -7
- package/src/createChat.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-openclaw-chat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Minimal chat SDK for Expo apps to connect to OpenClaw gateway",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -20,11 +20,22 @@
|
|
|
20
20
|
"@noble/hashes": "^2.0.0"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
+
"@expo/vector-icons": ">=14.0.0",
|
|
24
|
+
"expo-image-manipulator": ">=12.0.0",
|
|
25
|
+
"expo-image-picker": ">=14.0.0",
|
|
23
26
|
"expo-secure-store": ">=13.0.0",
|
|
24
27
|
"react": ">=18.0.0",
|
|
25
|
-
"react-native": ">=0.72.0"
|
|
28
|
+
"react-native": ">=0.72.0",
|
|
29
|
+
"react-native-keyboard-controller": ">=1.12.0",
|
|
30
|
+
"react-native-safe-area-context": ">=4.0.0"
|
|
26
31
|
},
|
|
27
32
|
"peerDependenciesMeta": {
|
|
33
|
+
"expo-image-manipulator": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"expo-image-picker": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
28
39
|
"expo-secure-store": {
|
|
29
40
|
"optional": true
|
|
30
41
|
}
|
|
@@ -73,6 +84,6 @@
|
|
|
73
84
|
"homepage": "https://github.com/brunobar79/expo-openclaw-chat",
|
|
74
85
|
"repository": {
|
|
75
86
|
"type": "git",
|
|
76
|
-
"url": "https://github.com/brunobar79/expo-openclaw-chat"
|
|
87
|
+
"url": "git+https://github.com/brunobar79/expo-openclaw-chat.git"
|
|
77
88
|
}
|
|
78
89
|
}
|
package/src/chat/engine.ts
CHANGED
|
@@ -199,10 +199,19 @@ export class ChatEngine {
|
|
|
199
199
|
idempotencyKey: generateIdempotencyKey(),
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
// Skip the placeholder if delta/complete events for this runId already
|
|
203
|
+
// arrived over the WS while we were awaiting chatSend — otherwise we'd
|
|
204
|
+
// append an empty bubble next to the already-rendered reply.
|
|
205
|
+
const alreadyHandled = this._messages.some(
|
|
206
|
+
(m) => m.runId === response.runId && m.role === "assistant",
|
|
207
|
+
);
|
|
208
|
+
if (alreadyHandled) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
202
212
|
this._activeRunId = response.runId;
|
|
203
213
|
this._isStreaming = true;
|
|
204
214
|
|
|
205
|
-
// Add streaming placeholder
|
|
206
215
|
const placeholder: UIMessage = {
|
|
207
216
|
id: `asst-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
208
217
|
role: "assistant",
|
package/src/core/client.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface GatewayClientOptions {
|
|
|
65
65
|
platform?: string;
|
|
66
66
|
/** Client ID for gateway registration (default: openclaw-ios) */
|
|
67
67
|
clientId?: string;
|
|
68
|
+
/** Extra headers to include in the WebSocket upgrade request (React Native only) */
|
|
69
|
+
headers?: Record<string, string>;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
// ─── Event Listener Types ───────────────────────────────────────────────────────
|
|
@@ -166,6 +168,10 @@ export class GatewayClient {
|
|
|
166
168
|
// Pairing flow state
|
|
167
169
|
private awaitingPairing = false;
|
|
168
170
|
|
|
171
|
+
// Connection protection: when true, disconnect() is a no-op.
|
|
172
|
+
// Set during connect(), cleared on hello-ok or connect failure.
|
|
173
|
+
private _connectInFlight = false;
|
|
174
|
+
|
|
169
175
|
constructor(url: string, options: GatewayClientOptions = {}) {
|
|
170
176
|
this.url = url;
|
|
171
177
|
this.options = {
|
|
@@ -215,6 +221,7 @@ export class GatewayClient {
|
|
|
215
221
|
}
|
|
216
222
|
|
|
217
223
|
this.intentionalClose = false;
|
|
224
|
+
this._connectInFlight = true;
|
|
218
225
|
this.setConnectionState("connecting");
|
|
219
226
|
|
|
220
227
|
// Load device identity before opening WebSocket
|
|
@@ -231,6 +238,10 @@ export class GatewayClient {
|
|
|
231
238
|
* Cleanly disconnect from the gateway.
|
|
232
239
|
*/
|
|
233
240
|
disconnect(): void {
|
|
241
|
+
// Don't kill a connection that's mid-handshake (race with React effect cleanup)
|
|
242
|
+
if (this._connectInFlight) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
234
245
|
this.intentionalClose = true;
|
|
235
246
|
this.awaitingPairing = false;
|
|
236
247
|
this.clearReconnectTimer();
|
|
@@ -268,6 +279,32 @@ export class GatewayClient {
|
|
|
268
279
|
): Promise<T> {
|
|
269
280
|
return new Promise<T>((resolve, reject) => {
|
|
270
281
|
if (!this.ws || this._connectionState !== "connected") {
|
|
282
|
+
// If autoReconnect is enabled, wait up to 5s for reconnection
|
|
283
|
+
// instead of immediately rejecting. This prevents transient
|
|
284
|
+
// "Not connected" errors during WS flaps (e.g. group chat fan-out).
|
|
285
|
+
if (this.options.autoReconnect !== false && this._connectionState !== "disconnected") {
|
|
286
|
+
const waitTimeout = 5000;
|
|
287
|
+
let settled = false;
|
|
288
|
+
const unsub = this.onConnectionStateChange((state) => {
|
|
289
|
+
if (settled) return;
|
|
290
|
+
if (state === "connected") {
|
|
291
|
+
settled = true;
|
|
292
|
+
unsub();
|
|
293
|
+
this.request<T>(method, params, timeoutMs).then(resolve, reject);
|
|
294
|
+
} else if (state === "disconnected") {
|
|
295
|
+
settled = true;
|
|
296
|
+
unsub();
|
|
297
|
+
reject(new Error("Not connected"));
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
setTimeout(() => {
|
|
301
|
+
if (settled) return;
|
|
302
|
+
settled = true;
|
|
303
|
+
unsub();
|
|
304
|
+
reject(new Error("Not connected"));
|
|
305
|
+
}, waitTimeout);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
271
308
|
return reject(new Error("Not connected"));
|
|
272
309
|
}
|
|
273
310
|
|
|
@@ -507,7 +544,19 @@ export class GatewayClient {
|
|
|
507
544
|
}
|
|
508
545
|
|
|
509
546
|
try {
|
|
510
|
-
|
|
547
|
+
const wsHeaders = this.options.headers;
|
|
548
|
+
if (wsHeaders && Object.keys(wsHeaders).length > 0) {
|
|
549
|
+
// React Native WebSocket accepts a 3rd argument for headers.
|
|
550
|
+
// Standard browser WebSocket does not, so we use a cast here.
|
|
551
|
+
const WS = WebSocket as unknown as new (
|
|
552
|
+
url: string,
|
|
553
|
+
protocols: null,
|
|
554
|
+
options: { headers: Record<string, string> },
|
|
555
|
+
) => WebSocket;
|
|
556
|
+
this.ws = new WS(url, null, { headers: wsHeaders });
|
|
557
|
+
} else {
|
|
558
|
+
this.ws = new WebSocket(url);
|
|
559
|
+
}
|
|
511
560
|
} catch (err) {
|
|
512
561
|
this.handleConnectFailure(err as Error);
|
|
513
562
|
return;
|
|
@@ -611,7 +660,6 @@ export class GatewayClient {
|
|
|
611
660
|
|
|
612
661
|
private handleEvent(frame: EventFrame): void {
|
|
613
662
|
const { event, payload, seq } = frame;
|
|
614
|
-
|
|
615
663
|
// Sequence gap detection
|
|
616
664
|
if (seq != null) {
|
|
617
665
|
if (this.lastSeq >= 0 && seq > this.lastSeq + 1) {
|
|
@@ -642,15 +690,17 @@ export class GatewayClient {
|
|
|
642
690
|
});
|
|
643
691
|
break;
|
|
644
692
|
|
|
645
|
-
case GatewayEvents.CHAT:
|
|
693
|
+
case GatewayEvents.CHAT: {
|
|
694
|
+
const chatPayload = payload as ChatEventPayload;
|
|
646
695
|
this.chatEventListeners.forEach((cb) => {
|
|
647
696
|
try {
|
|
648
|
-
cb(
|
|
697
|
+
cb(chatPayload);
|
|
649
698
|
} catch {
|
|
650
699
|
// Ignore listener errors to avoid breaking event dispatch
|
|
651
700
|
}
|
|
652
701
|
});
|
|
653
702
|
break;
|
|
703
|
+
}
|
|
654
704
|
|
|
655
705
|
case GatewayEvents.AGENT:
|
|
656
706
|
this.agentEventListeners.forEach((cb) => {
|
|
@@ -699,7 +749,6 @@ export class GatewayClient {
|
|
|
699
749
|
|
|
700
750
|
if (payload.decision === "approved") {
|
|
701
751
|
// Retry connect now that we're approved
|
|
702
|
-
console.log("[GatewayClient] Device approved, retrying connect...");
|
|
703
752
|
this.sendConnectFrame();
|
|
704
753
|
} else {
|
|
705
754
|
// Rejected - fail the connection
|
|
@@ -714,6 +763,7 @@ export class GatewayClient {
|
|
|
714
763
|
}
|
|
715
764
|
|
|
716
765
|
private handleHelloOk(helloOk: HelloOk): void {
|
|
766
|
+
this._connectInFlight = false;
|
|
717
767
|
this.helloOk = helloOk;
|
|
718
768
|
this.reconnectAttempt = 0;
|
|
719
769
|
this.lastSeq = -1;
|
|
@@ -770,9 +820,17 @@ export class GatewayClient {
|
|
|
770
820
|
const signedAtMs = Date.now();
|
|
771
821
|
const authToken = (auth.token as string) ?? (auth.deviceToken as string);
|
|
772
822
|
|
|
823
|
+
// Generate a random nonce if no challenge was received from the gateway.
|
|
824
|
+
// OpenClaw 2026.3.x requires the nonce field in all device connect params.
|
|
825
|
+
const nonce =
|
|
826
|
+
this.challengeNonce ??
|
|
827
|
+
Array.from(crypto.getRandomValues(new Uint8Array(16)))
|
|
828
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
829
|
+
.join("");
|
|
830
|
+
|
|
773
831
|
// Build signature payload (matches Swift implementation)
|
|
774
832
|
const payload = buildSignaturePayload({
|
|
775
|
-
nonce
|
|
833
|
+
nonce,
|
|
776
834
|
deviceId: identity.deviceId,
|
|
777
835
|
clientId,
|
|
778
836
|
clientMode,
|
|
@@ -791,7 +849,7 @@ export class GatewayClient {
|
|
|
791
849
|
publicKey,
|
|
792
850
|
signature,
|
|
793
851
|
signedAt: signedAtMs,
|
|
794
|
-
nonce
|
|
852
|
+
nonce,
|
|
795
853
|
};
|
|
796
854
|
}
|
|
797
855
|
}
|
|
@@ -851,6 +909,7 @@ export class GatewayClient {
|
|
|
851
909
|
}
|
|
852
910
|
|
|
853
911
|
private handleConnectFailure(error: Error): void {
|
|
912
|
+
this._connectInFlight = false;
|
|
854
913
|
if (this.connectPromiseReject) {
|
|
855
914
|
this.connectPromiseReject(error);
|
|
856
915
|
this.connectPromiseResolve = null;
|
package/src/createChat.tsx
CHANGED
|
@@ -69,7 +69,7 @@ export interface ChatInstance {
|
|
|
69
69
|
export function createChat(config: CreateChatConfig): ChatInstance {
|
|
70
70
|
const {
|
|
71
71
|
gatewayUrl,
|
|
72
|
-
sessionKey = `chat-${Date.now().toString(36)}`,
|
|
72
|
+
sessionKey = `agent:main:chat-${Date.now().toString(36)}`,
|
|
73
73
|
title,
|
|
74
74
|
placeholder,
|
|
75
75
|
showImagePicker,
|