@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/chat-protocol.ts
|
|
4
|
+
import {
|
|
5
|
+
wsServerMessageSchema
|
|
6
|
+
} from "@futurity/chat-protocol";
|
|
7
|
+
function parseServerMessage(data) {
|
|
8
|
+
const result = wsServerMessageSchema.safeParse(data);
|
|
9
|
+
if (!result.success) {
|
|
10
|
+
console.error("Failed to parse server message:", result.error, data);
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return result.data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/tree-builder.ts
|
|
17
|
+
var MessageNode = class {
|
|
18
|
+
id;
|
|
19
|
+
parent_id;
|
|
20
|
+
message;
|
|
21
|
+
children = [];
|
|
22
|
+
constructor(message) {
|
|
23
|
+
this.id = message.id;
|
|
24
|
+
this.message = message;
|
|
25
|
+
this.parent_id = message.metadata?.parent_id ?? null;
|
|
26
|
+
}
|
|
27
|
+
addChild(child) {
|
|
28
|
+
this.children.push(child);
|
|
29
|
+
}
|
|
30
|
+
getChildren() {
|
|
31
|
+
return this.children;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
function buildTree(messages, byId) {
|
|
35
|
+
const roots = [];
|
|
36
|
+
const orphans = [];
|
|
37
|
+
for (const message of messages) {
|
|
38
|
+
const node = new MessageNode(message);
|
|
39
|
+
byId.set(node.id, node);
|
|
40
|
+
if (node.parent_id === null) {
|
|
41
|
+
roots.push(node);
|
|
42
|
+
} else {
|
|
43
|
+
const parent = byId.get(node.parent_id);
|
|
44
|
+
if (parent) {
|
|
45
|
+
parent.addChild(node);
|
|
46
|
+
} else {
|
|
47
|
+
orphans.push(node);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const node of orphans) {
|
|
52
|
+
const parent = byId.get(node.parent_id ?? "");
|
|
53
|
+
if (parent) {
|
|
54
|
+
parent.addChild(node);
|
|
55
|
+
} else {
|
|
56
|
+
console.error(
|
|
57
|
+
`Could not find parent ${node.parent_id} for orphan node ${node.id}. Treating as root.`
|
|
58
|
+
);
|
|
59
|
+
roots.push(node);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return roots;
|
|
63
|
+
}
|
|
64
|
+
function findLatestPath(tree, byId) {
|
|
65
|
+
const leaves = [];
|
|
66
|
+
const stack = [...tree];
|
|
67
|
+
while (stack.length > 0) {
|
|
68
|
+
const node = stack.pop();
|
|
69
|
+
if (!node) continue;
|
|
70
|
+
const children = node.getChildren();
|
|
71
|
+
if (children.length === 0) {
|
|
72
|
+
leaves.push(node);
|
|
73
|
+
} else {
|
|
74
|
+
for (const child of children) {
|
|
75
|
+
stack.push(child);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (leaves.length === 0) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
let latestLeaf = leaves[0];
|
|
83
|
+
for (const leaf of leaves) {
|
|
84
|
+
if (leaf.message.createdAt && latestLeaf.message.createdAt && new Date(leaf.message.createdAt) > new Date(latestLeaf.message.createdAt)) {
|
|
85
|
+
latestLeaf = leaf;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const path = [];
|
|
89
|
+
let currentNode = latestLeaf;
|
|
90
|
+
while (currentNode) {
|
|
91
|
+
path.unshift(currentNode.message);
|
|
92
|
+
currentNode = byId.get(currentNode.parent_id ?? "");
|
|
93
|
+
}
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/types.ts
|
|
98
|
+
import { Z_MessagePart } from "@futurity/chat-protocol";
|
|
99
|
+
import { z } from "zod";
|
|
100
|
+
var messageMetadataSchema = z.object({
|
|
101
|
+
parent_id: z.uuid().nullable()
|
|
102
|
+
});
|
|
103
|
+
var Z_ChatMessage = z.looseObject({
|
|
104
|
+
id: z.string(),
|
|
105
|
+
role: z.enum(["user", "assistant", "system"]),
|
|
106
|
+
parts: z.array(Z_MessagePart),
|
|
107
|
+
createdAt: z.preprocess((val) => {
|
|
108
|
+
if (!val || typeof val !== "string") return void 0;
|
|
109
|
+
return new Date(val);
|
|
110
|
+
}, z.date().optional()),
|
|
111
|
+
metadata: messageMetadataSchema.optional()
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// src/useReconnectingWebSocket.ts
|
|
115
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
116
|
+
|
|
117
|
+
// src/WebSocketConnection.ts
|
|
118
|
+
var DEFAULT_HEARTBEAT_INTERVAL = 5e3;
|
|
119
|
+
var DEFAULT_HEARTBEAT_TIMEOUT = 1e4;
|
|
120
|
+
var DEFAULT_INITIAL_RECONNECT_DELAY = 1e3;
|
|
121
|
+
var DEFAULT_MAX_RECONNECT_DELAY = 3e4;
|
|
122
|
+
var WebSocketConnection = class {
|
|
123
|
+
ws = null;
|
|
124
|
+
reconnectTimeout = null;
|
|
125
|
+
heartbeatIntervalTimer = null;
|
|
126
|
+
heartbeatTimeoutTimer = null;
|
|
127
|
+
reconnectDelay;
|
|
128
|
+
pendingMessages = [];
|
|
129
|
+
intentionalClose = false;
|
|
130
|
+
awaitingPong = false;
|
|
131
|
+
connectPromise = null;
|
|
132
|
+
_state = "disconnected";
|
|
133
|
+
url;
|
|
134
|
+
heartbeatInterval;
|
|
135
|
+
heartbeatTimeout;
|
|
136
|
+
initialReconnectDelay;
|
|
137
|
+
maxReconnectDelay;
|
|
138
|
+
debugPrefix;
|
|
139
|
+
onMessage;
|
|
140
|
+
onConnectionChange;
|
|
141
|
+
onError;
|
|
142
|
+
get state() {
|
|
143
|
+
return this._state;
|
|
144
|
+
}
|
|
145
|
+
get isConnected() {
|
|
146
|
+
return this._state === "connected";
|
|
147
|
+
}
|
|
148
|
+
constructor(options) {
|
|
149
|
+
this.url = options.url;
|
|
150
|
+
this.onMessage = options.onMessage;
|
|
151
|
+
this.onConnectionChange = options.onConnectionChange;
|
|
152
|
+
this.onError = options.onError;
|
|
153
|
+
this.heartbeatInterval = options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
154
|
+
this.heartbeatTimeout = options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT;
|
|
155
|
+
this.initialReconnectDelay = options.initialReconnectDelay ?? DEFAULT_INITIAL_RECONNECT_DELAY;
|
|
156
|
+
this.maxReconnectDelay = options.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY;
|
|
157
|
+
this.debugPrefix = options.debugPrefix ?? "[WS]";
|
|
158
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
159
|
+
}
|
|
160
|
+
updateState(state) {
|
|
161
|
+
this._state = state;
|
|
162
|
+
this.onConnectionChange?.(state);
|
|
163
|
+
}
|
|
164
|
+
clearTimers() {
|
|
165
|
+
if (this.reconnectTimeout) {
|
|
166
|
+
clearTimeout(this.reconnectTimeout);
|
|
167
|
+
this.reconnectTimeout = null;
|
|
168
|
+
}
|
|
169
|
+
if (this.heartbeatIntervalTimer) {
|
|
170
|
+
clearInterval(this.heartbeatIntervalTimer);
|
|
171
|
+
this.heartbeatIntervalTimer = null;
|
|
172
|
+
}
|
|
173
|
+
if (this.heartbeatTimeoutTimer) {
|
|
174
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
175
|
+
this.heartbeatTimeoutTimer = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
sendPing() {
|
|
179
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
180
|
+
if (this.awaitingPong) {
|
|
181
|
+
console.warn(`${this.debugPrefix} No pong received, connection stale`);
|
|
182
|
+
this.ws.close();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.awaitingPong = true;
|
|
186
|
+
this.ws.send(JSON.stringify({ type: "__ping__" }));
|
|
187
|
+
this.heartbeatTimeoutTimer = setTimeout(() => {
|
|
188
|
+
if (this.awaitingPong) {
|
|
189
|
+
console.warn(`${this.debugPrefix} Pong timeout, closing connection`);
|
|
190
|
+
this.ws?.close();
|
|
191
|
+
}
|
|
192
|
+
}, this.heartbeatTimeout);
|
|
193
|
+
}
|
|
194
|
+
startHeartbeat() {
|
|
195
|
+
if (this.heartbeatIntervalTimer) {
|
|
196
|
+
clearInterval(this.heartbeatIntervalTimer);
|
|
197
|
+
}
|
|
198
|
+
this.heartbeatIntervalTimer = setInterval(
|
|
199
|
+
() => this.sendPing(),
|
|
200
|
+
this.heartbeatInterval
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
connect() {
|
|
204
|
+
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.clearTimers();
|
|
208
|
+
this.intentionalClose = false;
|
|
209
|
+
const wsUrl = this.url.replace(/^http/, "ws");
|
|
210
|
+
console.log(`${this.debugPrefix} Connecting to:`, wsUrl);
|
|
211
|
+
this.updateState("connecting");
|
|
212
|
+
const ws = new WebSocket(wsUrl);
|
|
213
|
+
ws.onopen = () => {
|
|
214
|
+
console.log(`${this.debugPrefix} Connected`);
|
|
215
|
+
this.updateState("connected");
|
|
216
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
217
|
+
this.awaitingPong = false;
|
|
218
|
+
for (const msg of this.pendingMessages) {
|
|
219
|
+
ws.send(JSON.stringify(msg));
|
|
220
|
+
}
|
|
221
|
+
this.pendingMessages = [];
|
|
222
|
+
this.startHeartbeat();
|
|
223
|
+
this.connectPromise?.resolve(true);
|
|
224
|
+
this.connectPromise = null;
|
|
225
|
+
};
|
|
226
|
+
ws.onmessage = (event) => {
|
|
227
|
+
try {
|
|
228
|
+
const data = JSON.parse(event.data);
|
|
229
|
+
if (typeof data === "object" && data !== null && "type" in data && data.type === "__pong__") {
|
|
230
|
+
this.awaitingPong = false;
|
|
231
|
+
if (this.heartbeatTimeoutTimer) {
|
|
232
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
233
|
+
this.heartbeatTimeoutTimer = null;
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.onMessage(data);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error(`${this.debugPrefix} Error parsing message:`, error);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
ws.onclose = () => {
|
|
243
|
+
console.log(`${this.debugPrefix} Disconnected`);
|
|
244
|
+
this.updateState("disconnected");
|
|
245
|
+
this.clearTimers();
|
|
246
|
+
this.connectPromise?.resolve(false);
|
|
247
|
+
this.connectPromise = null;
|
|
248
|
+
if (this.intentionalClose) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const delay = this.reconnectDelay;
|
|
252
|
+
console.log(`${this.debugPrefix} Reconnecting in ${delay}ms...`);
|
|
253
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
254
|
+
if (this.ws === ws) {
|
|
255
|
+
this.reconnectDelay = Math.min(
|
|
256
|
+
this.reconnectDelay * 2,
|
|
257
|
+
this.maxReconnectDelay
|
|
258
|
+
);
|
|
259
|
+
this.connect();
|
|
260
|
+
}
|
|
261
|
+
}, delay);
|
|
262
|
+
};
|
|
263
|
+
ws.onerror = (error) => {
|
|
264
|
+
if (ws.readyState !== WebSocket.CLOSED) {
|
|
265
|
+
console.error(`${this.debugPrefix} WebSocket error`);
|
|
266
|
+
this.onError?.(error);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
this.ws = ws;
|
|
270
|
+
}
|
|
271
|
+
disconnect() {
|
|
272
|
+
this.intentionalClose = true;
|
|
273
|
+
this.clearTimers();
|
|
274
|
+
if (this.ws) {
|
|
275
|
+
this.ws.onclose = null;
|
|
276
|
+
this.ws.onerror = null;
|
|
277
|
+
this.ws.close();
|
|
278
|
+
this.ws = null;
|
|
279
|
+
}
|
|
280
|
+
this.updateState("disconnected");
|
|
281
|
+
}
|
|
282
|
+
send(message) {
|
|
283
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
284
|
+
this.ws.send(JSON.stringify(message));
|
|
285
|
+
} else {
|
|
286
|
+
this.pendingMessages.push(message);
|
|
287
|
+
if (this.ws?.readyState !== WebSocket.CONNECTING) {
|
|
288
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
289
|
+
this.connect();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
ensureConnected() {
|
|
294
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
295
|
+
return Promise.resolve(true);
|
|
296
|
+
}
|
|
297
|
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
this.connectPromise = { resolve };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
this.connectPromise = { resolve };
|
|
304
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
305
|
+
this.connect();
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
reconnect() {
|
|
309
|
+
this.disconnect();
|
|
310
|
+
this.reconnectDelay = this.initialReconnectDelay;
|
|
311
|
+
setTimeout(() => this.connect(), 100);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// src/useReconnectingWebSocket.ts
|
|
316
|
+
function useReconnectingWebSocket({
|
|
317
|
+
url,
|
|
318
|
+
onMessage,
|
|
319
|
+
onConnectionChange,
|
|
320
|
+
onError,
|
|
321
|
+
enabled = true,
|
|
322
|
+
heartbeatInterval,
|
|
323
|
+
heartbeatTimeout,
|
|
324
|
+
initialReconnectDelay,
|
|
325
|
+
maxReconnectDelay,
|
|
326
|
+
debugPrefix
|
|
327
|
+
}) {
|
|
328
|
+
const [connectionState, setConnectionState] = useState("disconnected");
|
|
329
|
+
const onMessageRef = useRef(onMessage);
|
|
330
|
+
const onConnectionChangeRef = useRef(onConnectionChange);
|
|
331
|
+
const onErrorRef = useRef(onError);
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
onMessageRef.current = onMessage;
|
|
334
|
+
onConnectionChangeRef.current = onConnectionChange;
|
|
335
|
+
onErrorRef.current = onError;
|
|
336
|
+
});
|
|
337
|
+
const connRef = useRef(null);
|
|
338
|
+
if (!connRef.current) {
|
|
339
|
+
connRef.current = new WebSocketConnection({
|
|
340
|
+
url,
|
|
341
|
+
onMessage: (data) => onMessageRef.current(data),
|
|
342
|
+
onConnectionChange: (state) => {
|
|
343
|
+
setConnectionState(state);
|
|
344
|
+
onConnectionChangeRef.current?.(state);
|
|
345
|
+
},
|
|
346
|
+
onError: (error) => onErrorRef.current?.(error),
|
|
347
|
+
heartbeatInterval,
|
|
348
|
+
heartbeatTimeout,
|
|
349
|
+
initialReconnectDelay,
|
|
350
|
+
maxReconnectDelay,
|
|
351
|
+
debugPrefix
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (enabled) {
|
|
356
|
+
connRef.current?.connect();
|
|
357
|
+
}
|
|
358
|
+
return () => {
|
|
359
|
+
connRef.current?.disconnect();
|
|
360
|
+
};
|
|
361
|
+
}, [enabled]);
|
|
362
|
+
const send = useCallback((message) => {
|
|
363
|
+
connRef.current?.send(message);
|
|
364
|
+
}, []);
|
|
365
|
+
const ensureConnected = useCallback(() => {
|
|
366
|
+
return connRef.current?.ensureConnected() ?? Promise.resolve(false);
|
|
367
|
+
}, []);
|
|
368
|
+
const reconnect = useCallback(() => {
|
|
369
|
+
connRef.current?.reconnect();
|
|
370
|
+
}, []);
|
|
371
|
+
return {
|
|
372
|
+
connectionState,
|
|
373
|
+
isConnected: connectionState === "connected",
|
|
374
|
+
send,
|
|
375
|
+
ensureConnected,
|
|
376
|
+
reconnect
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/useStreamChat.ts
|
|
381
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
382
|
+
function transformChatHistory(rawMessages) {
|
|
383
|
+
const parsedMessages = Z_ChatMessage.array().safeParse(rawMessages);
|
|
384
|
+
if (!parsedMessages.success) {
|
|
385
|
+
throw new Error("Invalid chat history");
|
|
386
|
+
}
|
|
387
|
+
const messages = parsedMessages.data;
|
|
388
|
+
const byId = /* @__PURE__ */ new Map();
|
|
389
|
+
const tree = buildTree(messages, byId);
|
|
390
|
+
const initialPath = findLatestPath(tree, byId);
|
|
391
|
+
return {
|
|
392
|
+
messages,
|
|
393
|
+
tree,
|
|
394
|
+
byId,
|
|
395
|
+
initialPath
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function useStreamChat({
|
|
399
|
+
chatId,
|
|
400
|
+
wsUrl,
|
|
401
|
+
onStart,
|
|
402
|
+
onDelta,
|
|
403
|
+
onResume,
|
|
404
|
+
onFinish,
|
|
405
|
+
onError,
|
|
406
|
+
onHistory
|
|
407
|
+
}) {
|
|
408
|
+
if (!chatId) {
|
|
409
|
+
throw new Error("useStreamChat: chatId is required");
|
|
410
|
+
}
|
|
411
|
+
if (!wsUrl) {
|
|
412
|
+
throw new Error("useStreamChat: wsUrl is required");
|
|
413
|
+
}
|
|
414
|
+
const [messages, setMessages] = useState2([]);
|
|
415
|
+
const [status, setStatus] = useState2("ready");
|
|
416
|
+
const [job, setJob] = useState2("");
|
|
417
|
+
const hasRequestedChat = useRef2(false);
|
|
418
|
+
const [pendingClarify, setPendingClarify] = useState2(null);
|
|
419
|
+
const onStartRef = useRef2(onStart);
|
|
420
|
+
const onDeltaRef = useRef2(onDelta);
|
|
421
|
+
const onResumeRef = useRef2(onResume);
|
|
422
|
+
const onFinishRef = useRef2(onFinish);
|
|
423
|
+
const onErrorRef = useRef2(onError);
|
|
424
|
+
const onHistoryRef = useRef2(onHistory);
|
|
425
|
+
const sendRef = useRef2(() => {
|
|
426
|
+
});
|
|
427
|
+
useEffect2(() => {
|
|
428
|
+
onStartRef.current = onStart;
|
|
429
|
+
onDeltaRef.current = onDelta;
|
|
430
|
+
onResumeRef.current = onResume;
|
|
431
|
+
onFinishRef.current = onFinish;
|
|
432
|
+
onErrorRef.current = onError;
|
|
433
|
+
onHistoryRef.current = onHistory;
|
|
434
|
+
});
|
|
435
|
+
const handleMessage = useCallback2(
|
|
436
|
+
(rawData) => {
|
|
437
|
+
const message = parseServerMessage(rawData);
|
|
438
|
+
if (!message) {
|
|
439
|
+
console.error("Invalid message received from server");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
switch (message.type) {
|
|
443
|
+
case "ready": {
|
|
444
|
+
if (!hasRequestedChat.current) {
|
|
445
|
+
hasRequestedChat.current = true;
|
|
446
|
+
sendRef.current({
|
|
447
|
+
type: "get_chat",
|
|
448
|
+
data: { chat_id: chatId }
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
case "run": {
|
|
454
|
+
const { job_id, message_id } = message.data;
|
|
455
|
+
setStatus("submitted");
|
|
456
|
+
setJob(job_id);
|
|
457
|
+
onStartRef.current?.(message_id);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
case "stream": {
|
|
461
|
+
const messageId = message.messageId;
|
|
462
|
+
setStatus("streaming");
|
|
463
|
+
onDeltaRef.current?.(messageId, message.delta, message.consolidated);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case "stream_resume": {
|
|
467
|
+
const messageId = message.messageId;
|
|
468
|
+
setStatus("streaming");
|
|
469
|
+
onResumeRef.current?.(messageId, message.parts);
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
case "done": {
|
|
473
|
+
setStatus("ready");
|
|
474
|
+
setPendingClarify(null);
|
|
475
|
+
onFinishRef.current?.();
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case "error": {
|
|
479
|
+
setStatus("error");
|
|
480
|
+
onErrorRef.current?.(message);
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
case "chat_history": {
|
|
484
|
+
const result = transformChatHistory(message.data.messages);
|
|
485
|
+
result.activeMessageId = message.data.activeMessageId;
|
|
486
|
+
onHistoryRef.current?.(result);
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case "clarify_request": {
|
|
490
|
+
setPendingClarify(message.data);
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
case "cancel": {
|
|
494
|
+
setJob("");
|
|
495
|
+
setPendingClarify(null);
|
|
496
|
+
setTimeout(() => {
|
|
497
|
+
setStatus("ready");
|
|
498
|
+
}, 1e3);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
[chatId]
|
|
504
|
+
);
|
|
505
|
+
const handleConnectionChange = useCallback2((state) => {
|
|
506
|
+
if (state === "connected") {
|
|
507
|
+
setStatus("ready");
|
|
508
|
+
hasRequestedChat.current = false;
|
|
509
|
+
}
|
|
510
|
+
}, []);
|
|
511
|
+
const handleError = useCallback2(() => {
|
|
512
|
+
setStatus("error");
|
|
513
|
+
}, []);
|
|
514
|
+
const { send, ensureConnected, isConnected } = useReconnectingWebSocket({
|
|
515
|
+
url: wsUrl,
|
|
516
|
+
onMessage: handleMessage,
|
|
517
|
+
onConnectionChange: handleConnectionChange,
|
|
518
|
+
onError: handleError,
|
|
519
|
+
enabled: !!chatId,
|
|
520
|
+
debugPrefix: "[ChatWS]"
|
|
521
|
+
});
|
|
522
|
+
useEffect2(() => {
|
|
523
|
+
sendRef.current = send;
|
|
524
|
+
}, [send]);
|
|
525
|
+
const sendMessage = useCallback2(
|
|
526
|
+
async (payload) => {
|
|
527
|
+
if (status === "streaming" || status === "submitted") return;
|
|
528
|
+
const connected = await ensureConnected();
|
|
529
|
+
if (!connected) {
|
|
530
|
+
console.error("[ChatWS] Failed to connect for sendMessage");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const parent_id = payload.metadata !== void 0 ? payload.metadata.parent_id ?? null : messages.at(-1)?.id ?? null;
|
|
534
|
+
const message = {
|
|
535
|
+
id: crypto.randomUUID(),
|
|
536
|
+
role: "user",
|
|
537
|
+
parts: payload.parts,
|
|
538
|
+
metadata: {
|
|
539
|
+
parent_id
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
543
|
+
setMessages((prev) => [...prev, message]);
|
|
544
|
+
send({
|
|
545
|
+
type: "run",
|
|
546
|
+
data: {
|
|
547
|
+
id: chatId,
|
|
548
|
+
message: {
|
|
549
|
+
id: message.id,
|
|
550
|
+
role: message.role,
|
|
551
|
+
parts: message.parts,
|
|
552
|
+
metadata: message.metadata
|
|
553
|
+
},
|
|
554
|
+
vaultItems: payload.vaultItems ?? [],
|
|
555
|
+
timezone
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
},
|
|
559
|
+
[chatId, ensureConnected, messages, send, status]
|
|
560
|
+
);
|
|
561
|
+
const stop = useCallback2(async () => {
|
|
562
|
+
if (!job) return;
|
|
563
|
+
const connected = await ensureConnected();
|
|
564
|
+
if (!connected) return;
|
|
565
|
+
send({ type: "cancel", data: { job_id: job } });
|
|
566
|
+
}, [job, ensureConnected, send]);
|
|
567
|
+
const injectMessage = useCallback2(
|
|
568
|
+
async (text) => {
|
|
569
|
+
if (!job) return;
|
|
570
|
+
const connected = await ensureConnected();
|
|
571
|
+
if (!connected) return;
|
|
572
|
+
const message_id = crypto.randomUUID();
|
|
573
|
+
setMessages((prev) => [
|
|
574
|
+
...prev,
|
|
575
|
+
{
|
|
576
|
+
id: message_id,
|
|
577
|
+
role: "user",
|
|
578
|
+
parts: [{ type: "text", text }]
|
|
579
|
+
}
|
|
580
|
+
]);
|
|
581
|
+
send({
|
|
582
|
+
type: "inject_message",
|
|
583
|
+
data: {
|
|
584
|
+
job_id: job,
|
|
585
|
+
text,
|
|
586
|
+
message_id
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
},
|
|
590
|
+
[job, ensureConnected, send]
|
|
591
|
+
);
|
|
592
|
+
const sendClarifyResponse = useCallback2(
|
|
593
|
+
(answers) => {
|
|
594
|
+
if (!pendingClarify) return;
|
|
595
|
+
send({
|
|
596
|
+
type: "clarify_response",
|
|
597
|
+
data: {
|
|
598
|
+
job_id: pendingClarify.job_id,
|
|
599
|
+
requestId: pendingClarify.requestId,
|
|
600
|
+
answers
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
setPendingClarify(null);
|
|
604
|
+
},
|
|
605
|
+
[pendingClarify, send]
|
|
606
|
+
);
|
|
607
|
+
const reset = useCallback2(() => {
|
|
608
|
+
setMessages([]);
|
|
609
|
+
setStatus("ready");
|
|
610
|
+
setJob("");
|
|
611
|
+
setPendingClarify(null);
|
|
612
|
+
hasRequestedChat.current = false;
|
|
613
|
+
}, []);
|
|
614
|
+
return {
|
|
615
|
+
messages,
|
|
616
|
+
setMessages,
|
|
617
|
+
sendMessage,
|
|
618
|
+
injectMessage,
|
|
619
|
+
status,
|
|
620
|
+
stop,
|
|
621
|
+
reset,
|
|
622
|
+
isConnected,
|
|
623
|
+
pendingClarify,
|
|
624
|
+
sendClarifyResponse
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
export {
|
|
628
|
+
MessageNode,
|
|
629
|
+
WebSocketConnection,
|
|
630
|
+
Z_ChatMessage,
|
|
631
|
+
buildTree,
|
|
632
|
+
findLatestPath,
|
|
633
|
+
messageMetadataSchema,
|
|
634
|
+
parseServerMessage,
|
|
635
|
+
useReconnectingWebSocket,
|
|
636
|
+
useStreamChat
|
|
637
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@futurity/chat-react",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
},
|
|
12
|
+
"./*": "./src/*.ts"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"files": ["dist", "src", "README.md"],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"check": "tsgo --noEmit",
|
|
20
|
+
"prepublishOnly": "tsup"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@futurity/chat-protocol": "workspace:*"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
27
|
+
"zod": "^4.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^19",
|
|
31
|
+
"react": "^19",
|
|
32
|
+
"tsup": "^8.5.0",
|
|
33
|
+
"zod": "^4.1.13"
|
|
34
|
+
}
|
|
35
|
+
}
|