@futurity/chat-react 0.0.1
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/README.md +153 -0
- package/dist/index.d.ts +414 -0
- package/dist/index.js +637 -0
- package/package.json +35 -0
- package/src/WebSocketConnection.ts +284 -0
- package/src/chat-protocol.ts +22 -0
- package/src/index.ts +39 -0
- package/src/tree-builder.ts +116 -0
- package/src/types.ts +63 -0
- package/src/useReconnectingWebSocket.ts +126 -0
- package/src/useStreamChat.ts +354 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
export type ConnectionState = "disconnected" | "connecting" | "connected";
|
|
2
|
+
|
|
3
|
+
export type WebSocketConnectionOptions = {
|
|
4
|
+
/** WebSocket URL (can be relative, will be converted to ws/wss) */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Called when a parsed message is received */
|
|
7
|
+
onMessage: (message: unknown) => void;
|
|
8
|
+
/** Called when connection state changes */
|
|
9
|
+
onConnectionChange?: (state: ConnectionState) => void;
|
|
10
|
+
/** Called when an error occurs */
|
|
11
|
+
onError?: (error: Event) => void;
|
|
12
|
+
/** Heartbeat interval in ms (default: 5000) */
|
|
13
|
+
heartbeatInterval?: number;
|
|
14
|
+
/** How long to wait for pong before considering connection dead (default: 10000) */
|
|
15
|
+
heartbeatTimeout?: number;
|
|
16
|
+
/** Initial reconnection delay in ms (default: 1000) */
|
|
17
|
+
initialReconnectDelay?: number;
|
|
18
|
+
/** Maximum reconnection delay in ms (default: 30000) */
|
|
19
|
+
maxReconnectDelay?: number;
|
|
20
|
+
/** Log prefix for debugging */
|
|
21
|
+
debugPrefix?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const DEFAULT_HEARTBEAT_INTERVAL = 5_000;
|
|
25
|
+
const DEFAULT_HEARTBEAT_TIMEOUT = 10_000;
|
|
26
|
+
const DEFAULT_INITIAL_RECONNECT_DELAY = 1_000;
|
|
27
|
+
const DEFAULT_MAX_RECONNECT_DELAY = 30_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Framework-agnostic WebSocket connection with automatic reconnection and heartbeat.
|
|
31
|
+
*
|
|
32
|
+
* Handles:
|
|
33
|
+
* - Connection lifecycle (connect / disconnect / ensureConnected)
|
|
34
|
+
* - Reconnection with exponential backoff
|
|
35
|
+
* - Heartbeat ping/pong to detect stale connections
|
|
36
|
+
* - Message queue while disconnected
|
|
37
|
+
* - JSON serialization on send, JSON parsing on receive
|
|
38
|
+
*/
|
|
39
|
+
export class WebSocketConnection {
|
|
40
|
+
private ws: WebSocket | null = null;
|
|
41
|
+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
42
|
+
private heartbeatIntervalTimer: ReturnType<typeof setInterval> | null = null;
|
|
43
|
+
private heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
44
|
+
private reconnectDelay: number;
|
|
45
|
+
private pendingMessages: unknown[] = [];
|
|
46
|
+
private intentionalClose = false;
|
|
47
|
+
private awaitingPong = false;
|
|
48
|
+
private connectPromise: {
|
|
49
|
+
resolve: (connected: boolean) => void;
|
|
50
|
+
} | null = null;
|
|
51
|
+
|
|
52
|
+
private _state: ConnectionState = "disconnected";
|
|
53
|
+
|
|
54
|
+
private readonly url: string;
|
|
55
|
+
private readonly heartbeatInterval: number;
|
|
56
|
+
private readonly heartbeatTimeout: number;
|
|
57
|
+
private readonly initialReconnectDelay: number;
|
|
58
|
+
private readonly maxReconnectDelay: number;
|
|
59
|
+
private readonly debugPrefix: string;
|
|
60
|
+
|
|
61
|
+
onMessage: (message: unknown) => void;
|
|
62
|
+
onConnectionChange?: (state: ConnectionState) => void;
|
|
63
|
+
onError?: (error: Event) => void;
|
|
64
|
+
|
|
65
|
+
get state(): ConnectionState {
|
|
66
|
+
return this._state;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get isConnected(): boolean {
|
|
70
|
+
return this._state === "connected";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
constructor(options: WebSocketConnectionOptions) {
|
|
74
|
+
this.url = options.url;
|
|
75
|
+
this.onMessage = options.onMessage;
|
|
76
|
+
this.onConnectionChange = options.onConnectionChange;
|
|
77
|
+
this.onError = options.onError;
|
|
78
|
+
this.heartbeatInterval =
|
|
79
|
+
options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
80
|
+
this.heartbeatTimeout =
|
|
81
|
+
options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT;
|
|
82
|
+
this.initialReconnectDelay =
|
|
83
|
+
options.initialReconnectDelay ?? DEFAULT_INITIAL_RECONNECT_DELAY;
|
|
84
|
+
this.maxReconnectDelay =
|
|
85
|
+
options.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY;
|
|
86
|
+
this.debugPrefix = options.debugPrefix ?? "[WS]";
|
|
87
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private updateState(state: ConnectionState): void {
|
|
91
|
+
this._state = state;
|
|
92
|
+
this.onConnectionChange?.(state);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private clearTimers(): void {
|
|
96
|
+
if (this.reconnectTimeout) {
|
|
97
|
+
clearTimeout(this.reconnectTimeout);
|
|
98
|
+
this.reconnectTimeout = null;
|
|
99
|
+
}
|
|
100
|
+
if (this.heartbeatIntervalTimer) {
|
|
101
|
+
clearInterval(this.heartbeatIntervalTimer);
|
|
102
|
+
this.heartbeatIntervalTimer = null;
|
|
103
|
+
}
|
|
104
|
+
if (this.heartbeatTimeoutTimer) {
|
|
105
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
106
|
+
this.heartbeatTimeoutTimer = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private sendPing(): void {
|
|
111
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
112
|
+
|
|
113
|
+
if (this.awaitingPong) {
|
|
114
|
+
console.warn(`${this.debugPrefix} No pong received, connection stale`);
|
|
115
|
+
this.ws.close();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.awaitingPong = true;
|
|
120
|
+
this.ws.send(JSON.stringify({ type: "__ping__" }));
|
|
121
|
+
|
|
122
|
+
this.heartbeatTimeoutTimer = setTimeout(() => {
|
|
123
|
+
if (this.awaitingPong) {
|
|
124
|
+
console.warn(`${this.debugPrefix} Pong timeout, closing connection`);
|
|
125
|
+
this.ws?.close();
|
|
126
|
+
}
|
|
127
|
+
}, this.heartbeatTimeout);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private startHeartbeat(): void {
|
|
131
|
+
if (this.heartbeatIntervalTimer) {
|
|
132
|
+
clearInterval(this.heartbeatIntervalTimer);
|
|
133
|
+
}
|
|
134
|
+
this.heartbeatIntervalTimer = setInterval(
|
|
135
|
+
() => this.sendPing(),
|
|
136
|
+
this.heartbeatInterval,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
connect(): void {
|
|
141
|
+
if (
|
|
142
|
+
this.ws?.readyState === WebSocket.OPEN ||
|
|
143
|
+
this.ws?.readyState === WebSocket.CONNECTING
|
|
144
|
+
) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.clearTimers();
|
|
149
|
+
this.intentionalClose = false;
|
|
150
|
+
|
|
151
|
+
const wsUrl = this.url.replace(/^http/, "ws");
|
|
152
|
+
console.log(`${this.debugPrefix} Connecting to:`, wsUrl);
|
|
153
|
+
|
|
154
|
+
this.updateState("connecting");
|
|
155
|
+
const ws = new WebSocket(wsUrl);
|
|
156
|
+
|
|
157
|
+
ws.onopen = () => {
|
|
158
|
+
console.log(`${this.debugPrefix} Connected`);
|
|
159
|
+
this.updateState("connected");
|
|
160
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
161
|
+
this.awaitingPong = false;
|
|
162
|
+
|
|
163
|
+
for (const msg of this.pendingMessages) {
|
|
164
|
+
ws.send(JSON.stringify(msg));
|
|
165
|
+
}
|
|
166
|
+
this.pendingMessages = [];
|
|
167
|
+
|
|
168
|
+
this.startHeartbeat();
|
|
169
|
+
|
|
170
|
+
this.connectPromise?.resolve(true);
|
|
171
|
+
this.connectPromise = null;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
ws.onmessage = (event) => {
|
|
175
|
+
try {
|
|
176
|
+
const data: unknown = JSON.parse(event.data);
|
|
177
|
+
|
|
178
|
+
if (
|
|
179
|
+
typeof data === "object" &&
|
|
180
|
+
data !== null &&
|
|
181
|
+
"type" in data &&
|
|
182
|
+
(data as { type: unknown }).type === "__pong__"
|
|
183
|
+
) {
|
|
184
|
+
this.awaitingPong = false;
|
|
185
|
+
if (this.heartbeatTimeoutTimer) {
|
|
186
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
187
|
+
this.heartbeatTimeoutTimer = null;
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.onMessage(data);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error(`${this.debugPrefix} Error parsing message:`, error);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
ws.onclose = () => {
|
|
199
|
+
console.log(`${this.debugPrefix} Disconnected`);
|
|
200
|
+
this.updateState("disconnected");
|
|
201
|
+
this.clearTimers();
|
|
202
|
+
|
|
203
|
+
this.connectPromise?.resolve(false);
|
|
204
|
+
this.connectPromise = null;
|
|
205
|
+
|
|
206
|
+
if (this.intentionalClose) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const delay = this.reconnectDelay;
|
|
211
|
+
console.log(`${this.debugPrefix} Reconnecting in ${delay}ms...`);
|
|
212
|
+
|
|
213
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
214
|
+
if (this.ws === ws) {
|
|
215
|
+
this.reconnectDelay = Math.min(
|
|
216
|
+
this.reconnectDelay * 2,
|
|
217
|
+
this.maxReconnectDelay,
|
|
218
|
+
);
|
|
219
|
+
this.connect();
|
|
220
|
+
}
|
|
221
|
+
}, delay);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
ws.onerror = (error) => {
|
|
225
|
+
if (ws.readyState !== WebSocket.CLOSED) {
|
|
226
|
+
console.error(`${this.debugPrefix} WebSocket error`);
|
|
227
|
+
this.onError?.(error);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
this.ws = ws;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
disconnect(): void {
|
|
235
|
+
this.intentionalClose = true;
|
|
236
|
+
this.clearTimers();
|
|
237
|
+
|
|
238
|
+
if (this.ws) {
|
|
239
|
+
this.ws.onclose = null;
|
|
240
|
+
this.ws.onerror = null;
|
|
241
|
+
this.ws.close();
|
|
242
|
+
this.ws = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this.updateState("disconnected");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
send(message: unknown): void {
|
|
249
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
250
|
+
this.ws.send(JSON.stringify(message));
|
|
251
|
+
} else {
|
|
252
|
+
this.pendingMessages.push(message);
|
|
253
|
+
|
|
254
|
+
if (this.ws?.readyState !== WebSocket.CONNECTING) {
|
|
255
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
256
|
+
this.connect();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
ensureConnected(): Promise<boolean> {
|
|
262
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
263
|
+
return Promise.resolve(true);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
267
|
+
return new Promise((resolve) => {
|
|
268
|
+
this.connectPromise = { resolve };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
this.connectPromise = { resolve };
|
|
274
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
275
|
+
this.connect();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
reconnect(): void {
|
|
280
|
+
this.disconnect();
|
|
281
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
282
|
+
setTimeout(() => this.connect(), 100);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat WebSocket protocol helpers.
|
|
3
|
+
*
|
|
4
|
+
* All protocol schemas live in @futurity/chat-protocol;
|
|
5
|
+
* this module provides a lightweight parse helper for the SDK.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
type WsClientCommand,
|
|
9
|
+
type WsServerMessage,
|
|
10
|
+
wsServerMessageSchema,
|
|
11
|
+
} from "@futurity/chat-protocol";
|
|
12
|
+
|
|
13
|
+
export type { WsClientCommand as ClientCommand };
|
|
14
|
+
|
|
15
|
+
export function parseServerMessage(data: unknown): WsServerMessage | null {
|
|
16
|
+
const result = wsServerMessageSchema.safeParse(data);
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
console.error("Failed to parse server message:", result.error, data);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return result.data;
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Core WebSocket class (framework-agnostic)
|
|
2
|
+
|
|
3
|
+
// Hooks
|
|
4
|
+
export type { ClientCommand } from "./chat-protocol";
|
|
5
|
+
// Protocol helpers
|
|
6
|
+
export { parseServerMessage } from "./chat-protocol";
|
|
7
|
+
// Tree utilities
|
|
8
|
+
export { buildTree, findLatestPath, MessageNode } from "./tree-builder";
|
|
9
|
+
// Types
|
|
10
|
+
export type {
|
|
11
|
+
ChatMessage,
|
|
12
|
+
ChatStatus,
|
|
13
|
+
ClarifyData,
|
|
14
|
+
MessageMetadata,
|
|
15
|
+
MessagePart,
|
|
16
|
+
SendMessageFn,
|
|
17
|
+
SendMessagePayload,
|
|
18
|
+
StreamDelta,
|
|
19
|
+
WsClarifyQuestion,
|
|
20
|
+
WsClientCommand,
|
|
21
|
+
WsErrorMessage,
|
|
22
|
+
WsServerMessage,
|
|
23
|
+
WsStreamMessage,
|
|
24
|
+
WsVaultItem,
|
|
25
|
+
} from "./types";
|
|
26
|
+
export { messageMetadataSchema, Z_ChatMessage } from "./types";
|
|
27
|
+
export type {
|
|
28
|
+
ConnectionState,
|
|
29
|
+
WebSocketOptions,
|
|
30
|
+
WebSocketResult,
|
|
31
|
+
} from "./useReconnectingWebSocket";
|
|
32
|
+
export { useReconnectingWebSocket } from "./useReconnectingWebSocket";
|
|
33
|
+
export type {
|
|
34
|
+
UseStreamChatOptions,
|
|
35
|
+
UseStreamChatReturn,
|
|
36
|
+
} from "./useStreamChat";
|
|
37
|
+
export { useStreamChat } from "./useStreamChat";
|
|
38
|
+
export type { WebSocketConnectionOptions } from "./WebSocketConnection";
|
|
39
|
+
export { WebSocketConnection } from "./WebSocketConnection";
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ChatMessage } from "./types";
|
|
2
|
+
|
|
3
|
+
export class MessageNode {
|
|
4
|
+
public readonly id: string;
|
|
5
|
+
public readonly parent_id: string | null;
|
|
6
|
+
public readonly message: ChatMessage;
|
|
7
|
+
private children: MessageNode[] = [];
|
|
8
|
+
|
|
9
|
+
constructor(message: ChatMessage) {
|
|
10
|
+
this.id = message.id;
|
|
11
|
+
this.message = message;
|
|
12
|
+
this.parent_id = message.metadata?.parent_id ?? null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
addChild(child: MessageNode): void {
|
|
16
|
+
this.children.push(child);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getChildren(): readonly MessageNode[] {
|
|
20
|
+
return this.children;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Builds a tree of MessageNodes from a flat list of messages using a two-pass approach.
|
|
26
|
+
* This ensures that children are connected even if the parent appears later in the array.
|
|
27
|
+
* The input messages SHOULD BE SORTED by `created_at` ASC for optimal performance.
|
|
28
|
+
* @param messages - An array of ChatMessage objects.
|
|
29
|
+
* @param byId - An empty Map that will be populated with all nodes by their ID.
|
|
30
|
+
* @returns An array of root MessageNode(s).
|
|
31
|
+
*/
|
|
32
|
+
export function buildTree(
|
|
33
|
+
messages: ChatMessage[],
|
|
34
|
+
byId: Map<string, MessageNode>,
|
|
35
|
+
): MessageNode[] {
|
|
36
|
+
const roots: MessageNode[] = [];
|
|
37
|
+
const orphans: MessageNode[] = [];
|
|
38
|
+
|
|
39
|
+
for (const message of messages) {
|
|
40
|
+
const node = new MessageNode(message);
|
|
41
|
+
byId.set(node.id, node);
|
|
42
|
+
|
|
43
|
+
if (node.parent_id === null) {
|
|
44
|
+
roots.push(node);
|
|
45
|
+
} else {
|
|
46
|
+
const parent = byId.get(node.parent_id);
|
|
47
|
+
if (parent) {
|
|
48
|
+
parent.addChild(node);
|
|
49
|
+
} else {
|
|
50
|
+
orphans.push(node);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const node of orphans) {
|
|
56
|
+
const parent = byId.get(node.parent_id ?? "");
|
|
57
|
+
if (parent) {
|
|
58
|
+
parent.addChild(node);
|
|
59
|
+
} else {
|
|
60
|
+
console.error(
|
|
61
|
+
`Could not find parent ${node.parent_id} for orphan node ${node.id}. Treating as root.`,
|
|
62
|
+
);
|
|
63
|
+
roots.push(node);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return roots;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Finds the latest path in the conversation tree by following
|
|
72
|
+
* the leaf with the most recent `createdAt` timestamp.
|
|
73
|
+
*/
|
|
74
|
+
export function findLatestPath(
|
|
75
|
+
tree: MessageNode[],
|
|
76
|
+
byId: Map<string, MessageNode>,
|
|
77
|
+
): ChatMessage[] {
|
|
78
|
+
const leaves: MessageNode[] = [];
|
|
79
|
+
|
|
80
|
+
const stack: MessageNode[] = [...tree];
|
|
81
|
+
while (stack.length > 0) {
|
|
82
|
+
const node = stack.pop();
|
|
83
|
+
if (!node) continue;
|
|
84
|
+
const children = node.getChildren();
|
|
85
|
+
if (children.length === 0) {
|
|
86
|
+
leaves.push(node);
|
|
87
|
+
} else {
|
|
88
|
+
for (const child of children) {
|
|
89
|
+
stack.push(child);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (leaves.length === 0) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let latestLeaf = leaves[0];
|
|
99
|
+
for (const leaf of leaves) {
|
|
100
|
+
if (
|
|
101
|
+
leaf.message.createdAt &&
|
|
102
|
+
latestLeaf.message.createdAt &&
|
|
103
|
+
new Date(leaf.message.createdAt) > new Date(latestLeaf.message.createdAt)
|
|
104
|
+
) {
|
|
105
|
+
latestLeaf = leaf;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const path: ChatMessage[] = [];
|
|
110
|
+
let currentNode: MessageNode | undefined = latestLeaf;
|
|
111
|
+
while (currentNode) {
|
|
112
|
+
path.unshift(currentNode.message);
|
|
113
|
+
currentNode = byId.get(currentNode.parent_id ?? "");
|
|
114
|
+
}
|
|
115
|
+
return path;
|
|
116
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WsClarifyQuestion,
|
|
3
|
+
WsClientCommand,
|
|
4
|
+
WsErrorMessage,
|
|
5
|
+
WsServerMessage,
|
|
6
|
+
WsStreamMessage,
|
|
7
|
+
WsVaultItem,
|
|
8
|
+
} from "@futurity/chat-protocol";
|
|
9
|
+
import { type MessagePart, Z_MessagePart } from "@futurity/chat-protocol";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
export type { MessagePart };
|
|
13
|
+
|
|
14
|
+
export const messageMetadataSchema = z.object({
|
|
15
|
+
parent_id: z.uuid().nullable(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type MessageMetadata = z.infer<typeof messageMetadataSchema>;
|
|
19
|
+
|
|
20
|
+
/** A chat message suitable for rendering in a UI. */
|
|
21
|
+
export type ChatMessage = {
|
|
22
|
+
id: string;
|
|
23
|
+
role: "user" | "assistant" | "system";
|
|
24
|
+
parts: MessagePart[];
|
|
25
|
+
createdAt?: Date;
|
|
26
|
+
metadata?: MessageMetadata;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const Z_ChatMessage = z.looseObject({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
role: z.enum(["user", "assistant", "system"]),
|
|
32
|
+
parts: z.array(Z_MessagePart),
|
|
33
|
+
createdAt: z.preprocess((val) => {
|
|
34
|
+
if (!val || typeof val !== "string") return undefined;
|
|
35
|
+
return new Date(val);
|
|
36
|
+
}, z.date().optional()),
|
|
37
|
+
metadata: messageMetadataSchema.optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type SendMessagePayload = {
|
|
41
|
+
parts: MessagePart[];
|
|
42
|
+
metadata?: MessageMetadata;
|
|
43
|
+
/** Optional vault items to attach to the message. */
|
|
44
|
+
vaultItems?: WsVaultItem[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type SendMessageFn = (payload: SendMessagePayload) => Promise<void>;
|
|
48
|
+
|
|
49
|
+
export type StreamDelta = WsStreamMessage["delta"];
|
|
50
|
+
|
|
51
|
+
export type ChatStatus = "ready" | "submitted" | "streaming" | "error";
|
|
52
|
+
|
|
53
|
+
export type ClarifyData = WsClarifyQuestion[];
|
|
54
|
+
|
|
55
|
+
/** Re-export protocol types for consumers. */
|
|
56
|
+
export type {
|
|
57
|
+
WsClientCommand,
|
|
58
|
+
WsServerMessage,
|
|
59
|
+
WsErrorMessage,
|
|
60
|
+
WsStreamMessage,
|
|
61
|
+
WsClarifyQuestion,
|
|
62
|
+
WsVaultItem,
|
|
63
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type ConnectionState,
|
|
4
|
+
WebSocketConnection,
|
|
5
|
+
} from "./WebSocketConnection";
|
|
6
|
+
|
|
7
|
+
export type { ConnectionState } from "./WebSocketConnection";
|
|
8
|
+
|
|
9
|
+
export type WebSocketOptions<TServerMessage> = {
|
|
10
|
+
/** WebSocket URL (can be relative, will be converted to ws/wss) */
|
|
11
|
+
url: string;
|
|
12
|
+
/** Called when a message is received */
|
|
13
|
+
onMessage: (message: TServerMessage) => void;
|
|
14
|
+
/** Called when connection state changes */
|
|
15
|
+
onConnectionChange?: (state: ConnectionState) => void;
|
|
16
|
+
/** Called when an error occurs */
|
|
17
|
+
onError?: (error: Event) => void;
|
|
18
|
+
/** Whether the WebSocket should be enabled (default: true) */
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
/** Heartbeat interval in ms (default: 5000 = 5s) */
|
|
21
|
+
heartbeatInterval?: number;
|
|
22
|
+
/** How long to wait for pong before considering connection dead (default: 10000 = 10s) */
|
|
23
|
+
heartbeatTimeout?: number;
|
|
24
|
+
/** Initial reconnection delay in ms (default: 1000) */
|
|
25
|
+
initialReconnectDelay?: number;
|
|
26
|
+
/** Maximum reconnection delay in ms (default: 30000) */
|
|
27
|
+
maxReconnectDelay?: number;
|
|
28
|
+
/** Log prefix for debugging */
|
|
29
|
+
debugPrefix?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type WebSocketResult<TClientCommand> = {
|
|
33
|
+
/** Current connection state */
|
|
34
|
+
connectionState: ConnectionState;
|
|
35
|
+
/** Whether the WebSocket is currently connected */
|
|
36
|
+
isConnected: boolean;
|
|
37
|
+
/** Send a message, auto-reconnecting if needed */
|
|
38
|
+
send: (message: TClientCommand) => void;
|
|
39
|
+
/** Ensure connection before performing an action */
|
|
40
|
+
ensureConnected: () => Promise<boolean>;
|
|
41
|
+
/** Force reconnection */
|
|
42
|
+
reconnect: () => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A React hook wrapping {@link WebSocketConnection} with lifecycle management.
|
|
47
|
+
*
|
|
48
|
+
* - Instantiates a `WebSocketConnection` in a ref
|
|
49
|
+
* - Manages connect/disconnect on mount/unmount and when `enabled` changes
|
|
50
|
+
* - Bridges connection events to React state and callback props
|
|
51
|
+
* - Exposes `send`, `ensureConnected`, `reconnect` with stable references
|
|
52
|
+
*/
|
|
53
|
+
export function useReconnectingWebSocket<TServerMessage, TClientCommand>({
|
|
54
|
+
url,
|
|
55
|
+
onMessage,
|
|
56
|
+
onConnectionChange,
|
|
57
|
+
onError,
|
|
58
|
+
enabled = true,
|
|
59
|
+
heartbeatInterval,
|
|
60
|
+
heartbeatTimeout,
|
|
61
|
+
initialReconnectDelay,
|
|
62
|
+
maxReconnectDelay,
|
|
63
|
+
debugPrefix,
|
|
64
|
+
}: WebSocketOptions<TServerMessage>): WebSocketResult<TClientCommand> {
|
|
65
|
+
const [connectionState, setConnectionState] =
|
|
66
|
+
useState<ConnectionState>("disconnected");
|
|
67
|
+
|
|
68
|
+
const onMessageRef = useRef(onMessage);
|
|
69
|
+
const onConnectionChangeRef = useRef(onConnectionChange);
|
|
70
|
+
const onErrorRef = useRef(onError);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
onMessageRef.current = onMessage;
|
|
74
|
+
onConnectionChangeRef.current = onConnectionChange;
|
|
75
|
+
onErrorRef.current = onError;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const connRef = useRef<WebSocketConnection | null>(null);
|
|
79
|
+
|
|
80
|
+
// Lazy-init the connection instance once
|
|
81
|
+
if (!connRef.current) {
|
|
82
|
+
connRef.current = new WebSocketConnection({
|
|
83
|
+
url,
|
|
84
|
+
onMessage: (data) => onMessageRef.current(data as TServerMessage),
|
|
85
|
+
onConnectionChange: (state) => {
|
|
86
|
+
setConnectionState(state);
|
|
87
|
+
onConnectionChangeRef.current?.(state);
|
|
88
|
+
},
|
|
89
|
+
onError: (error) => onErrorRef.current?.(error),
|
|
90
|
+
heartbeatInterval,
|
|
91
|
+
heartbeatTimeout,
|
|
92
|
+
initialReconnectDelay,
|
|
93
|
+
maxReconnectDelay,
|
|
94
|
+
debugPrefix,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (enabled) {
|
|
100
|
+
connRef.current?.connect();
|
|
101
|
+
}
|
|
102
|
+
return () => {
|
|
103
|
+
connRef.current?.disconnect();
|
|
104
|
+
};
|
|
105
|
+
}, [enabled]);
|
|
106
|
+
|
|
107
|
+
const send = useCallback((message: TClientCommand) => {
|
|
108
|
+
connRef.current?.send(message);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const ensureConnected = useCallback((): Promise<boolean> => {
|
|
112
|
+
return connRef.current?.ensureConnected() ?? Promise.resolve(false);
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const reconnect = useCallback(() => {
|
|
116
|
+
connRef.current?.reconnect();
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
connectionState,
|
|
121
|
+
isConnected: connectionState === "connected",
|
|
122
|
+
send,
|
|
123
|
+
ensureConnected,
|
|
124
|
+
reconnect,
|
|
125
|
+
};
|
|
126
|
+
}
|