@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,354 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MessagePart,
|
|
3
|
+
WsClarifyRequestMessage,
|
|
4
|
+
} from "@futurity/chat-protocol";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { type ClientCommand, parseServerMessage } from "./chat-protocol";
|
|
7
|
+
import { buildTree, findLatestPath, type MessageNode } from "./tree-builder";
|
|
8
|
+
import type {
|
|
9
|
+
ChatMessage,
|
|
10
|
+
ChatStatus,
|
|
11
|
+
SendMessagePayload,
|
|
12
|
+
StreamDelta,
|
|
13
|
+
} from "./types";
|
|
14
|
+
import { Z_ChatMessage } from "./types";
|
|
15
|
+
import { useReconnectingWebSocket } from "./useReconnectingWebSocket";
|
|
16
|
+
|
|
17
|
+
type TransformedHistory = {
|
|
18
|
+
messages: ChatMessage[];
|
|
19
|
+
tree: MessageNode[];
|
|
20
|
+
byId: Map<string, MessageNode>;
|
|
21
|
+
initialPath: ChatMessage[];
|
|
22
|
+
activeMessageId?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function transformChatHistory(rawMessages: unknown[]): TransformedHistory {
|
|
26
|
+
const parsedMessages = Z_ChatMessage.array().safeParse(rawMessages);
|
|
27
|
+
if (!parsedMessages.success) {
|
|
28
|
+
throw new Error("Invalid chat history");
|
|
29
|
+
}
|
|
30
|
+
const messages = parsedMessages.data;
|
|
31
|
+
const byId = new Map<string, MessageNode>();
|
|
32
|
+
const tree = buildTree(messages, byId);
|
|
33
|
+
const initialPath = findLatestPath(tree, byId);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
messages,
|
|
37
|
+
tree,
|
|
38
|
+
byId,
|
|
39
|
+
initialPath,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type UseStreamChatOptions = {
|
|
44
|
+
/** The chat ID to connect to. */
|
|
45
|
+
chatId: string;
|
|
46
|
+
/** Full WebSocket URL for the chat endpoint (e.g. `wss://api.example.com/api/v2/chat/<id>`). */
|
|
47
|
+
wsUrl: string;
|
|
48
|
+
/** Called when a new assistant message starts streaming. */
|
|
49
|
+
onStart?: (id: string) => void;
|
|
50
|
+
/** Called on each stream delta. */
|
|
51
|
+
onDelta?: (id: string, delta: StreamDelta, consolidated: boolean) => void;
|
|
52
|
+
/** Called when a stream resumes with accumulated parts. */
|
|
53
|
+
onResume?: (id: string, parts: MessagePart[]) => void;
|
|
54
|
+
/** Called when streaming finishes. */
|
|
55
|
+
onFinish?: () => void;
|
|
56
|
+
/** Called on a protocol error. */
|
|
57
|
+
onError?: (error: { error?: string; message?: string }) => void;
|
|
58
|
+
/** Called when chat history is received from the server. */
|
|
59
|
+
onHistory?: (history: TransformedHistory) => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type UseStreamChatReturn = {
|
|
63
|
+
/** Current list of messages in the active branch. */
|
|
64
|
+
messages: ChatMessage[];
|
|
65
|
+
/** Replace the messages list (e.g. for branch switching). */
|
|
66
|
+
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
|
67
|
+
/** Send a new user message. */
|
|
68
|
+
sendMessage: (payload: SendMessagePayload) => Promise<void>;
|
|
69
|
+
/** Inject a user message into an active stream. */
|
|
70
|
+
injectMessage: (text: string) => Promise<void>;
|
|
71
|
+
/** Current chat status. */
|
|
72
|
+
status: ChatStatus;
|
|
73
|
+
/** Stop the current generation. */
|
|
74
|
+
stop: () => Promise<void>;
|
|
75
|
+
/** Reset the chat state. */
|
|
76
|
+
reset: () => void;
|
|
77
|
+
/** Whether the WebSocket is connected. */
|
|
78
|
+
isConnected: boolean;
|
|
79
|
+
/** Pending clarification request, if any. */
|
|
80
|
+
pendingClarify: WsClarifyRequestMessage["data"] | null;
|
|
81
|
+
/** Submit answers to a clarification request. */
|
|
82
|
+
sendClarifyResponse: (answers: Record<string, string>) => void;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function useStreamChat({
|
|
86
|
+
chatId,
|
|
87
|
+
wsUrl,
|
|
88
|
+
onStart,
|
|
89
|
+
onDelta,
|
|
90
|
+
onResume,
|
|
91
|
+
onFinish,
|
|
92
|
+
onError,
|
|
93
|
+
onHistory,
|
|
94
|
+
}: UseStreamChatOptions): UseStreamChatReturn {
|
|
95
|
+
if (!chatId) {
|
|
96
|
+
throw new Error("useStreamChat: chatId is required");
|
|
97
|
+
}
|
|
98
|
+
if (!wsUrl) {
|
|
99
|
+
throw new Error("useStreamChat: wsUrl is required");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
103
|
+
const [status, setStatus] = useState<ChatStatus>("ready");
|
|
104
|
+
const [job, setJob] = useState("");
|
|
105
|
+
const hasRequestedChat = useRef(false);
|
|
106
|
+
const [pendingClarify, setPendingClarify] = useState<
|
|
107
|
+
WsClarifyRequestMessage["data"] | null
|
|
108
|
+
>(null);
|
|
109
|
+
|
|
110
|
+
const onStartRef = useRef(onStart);
|
|
111
|
+
const onDeltaRef = useRef(onDelta);
|
|
112
|
+
const onResumeRef = useRef(onResume);
|
|
113
|
+
const onFinishRef = useRef(onFinish);
|
|
114
|
+
const onErrorRef = useRef(onError);
|
|
115
|
+
const onHistoryRef = useRef(onHistory);
|
|
116
|
+
const sendRef = useRef<(cmd: ClientCommand) => void>(() => {});
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
onStartRef.current = onStart;
|
|
120
|
+
onDeltaRef.current = onDelta;
|
|
121
|
+
onResumeRef.current = onResume;
|
|
122
|
+
onFinishRef.current = onFinish;
|
|
123
|
+
onErrorRef.current = onError;
|
|
124
|
+
onHistoryRef.current = onHistory;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const handleMessage = useCallback(
|
|
128
|
+
(rawData: unknown) => {
|
|
129
|
+
const message = parseServerMessage(rawData);
|
|
130
|
+
|
|
131
|
+
if (!message) {
|
|
132
|
+
console.error("Invalid message received from server");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
switch (message.type) {
|
|
137
|
+
case "ready": {
|
|
138
|
+
if (!hasRequestedChat.current) {
|
|
139
|
+
hasRequestedChat.current = true;
|
|
140
|
+
sendRef.current({
|
|
141
|
+
type: "get_chat",
|
|
142
|
+
data: { chat_id: chatId },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "run": {
|
|
149
|
+
const { job_id, message_id } = message.data;
|
|
150
|
+
setStatus("submitted");
|
|
151
|
+
setJob(job_id);
|
|
152
|
+
onStartRef.current?.(message_id);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "stream": {
|
|
157
|
+
const messageId = message.messageId;
|
|
158
|
+
|
|
159
|
+
setStatus("streaming");
|
|
160
|
+
onDeltaRef.current?.(messageId, message.delta, message.consolidated);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "stream_resume": {
|
|
165
|
+
const messageId = message.messageId;
|
|
166
|
+
setStatus("streaming");
|
|
167
|
+
onResumeRef.current?.(messageId, message.parts);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "done": {
|
|
172
|
+
setStatus("ready");
|
|
173
|
+
setPendingClarify(null);
|
|
174
|
+
onFinishRef.current?.();
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case "error": {
|
|
179
|
+
setStatus("error");
|
|
180
|
+
onErrorRef.current?.(message);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case "chat_history": {
|
|
185
|
+
const result = transformChatHistory(message.data.messages);
|
|
186
|
+
result.activeMessageId = message.data.activeMessageId;
|
|
187
|
+
onHistoryRef.current?.(result);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case "clarify_request": {
|
|
192
|
+
setPendingClarify(message.data);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case "cancel": {
|
|
197
|
+
setJob("");
|
|
198
|
+
setPendingClarify(null);
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
setStatus("ready");
|
|
201
|
+
}, 1000);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
[chatId],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const handleConnectionChange = useCallback((state: string) => {
|
|
210
|
+
if (state === "connected") {
|
|
211
|
+
setStatus("ready");
|
|
212
|
+
hasRequestedChat.current = false;
|
|
213
|
+
}
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
const handleError = useCallback(() => {
|
|
217
|
+
setStatus("error");
|
|
218
|
+
}, []);
|
|
219
|
+
|
|
220
|
+
const { send, ensureConnected, isConnected } = useReconnectingWebSocket<
|
|
221
|
+
unknown,
|
|
222
|
+
ClientCommand
|
|
223
|
+
>({
|
|
224
|
+
url: wsUrl,
|
|
225
|
+
onMessage: handleMessage,
|
|
226
|
+
onConnectionChange: handleConnectionChange,
|
|
227
|
+
onError: handleError,
|
|
228
|
+
enabled: !!chatId,
|
|
229
|
+
debugPrefix: "[ChatWS]",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
sendRef.current = send;
|
|
234
|
+
}, [send]);
|
|
235
|
+
|
|
236
|
+
const sendMessage = useCallback(
|
|
237
|
+
async (payload: SendMessagePayload): Promise<void> => {
|
|
238
|
+
if (status === "streaming" || status === "submitted") return;
|
|
239
|
+
|
|
240
|
+
const connected = await ensureConnected();
|
|
241
|
+
if (!connected) {
|
|
242
|
+
console.error("[ChatWS] Failed to connect for sendMessage");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const parent_id =
|
|
247
|
+
payload.metadata !== undefined
|
|
248
|
+
? (payload.metadata.parent_id ?? null)
|
|
249
|
+
: (messages.at(-1)?.id ?? null);
|
|
250
|
+
|
|
251
|
+
const message: ChatMessage = {
|
|
252
|
+
id: crypto.randomUUID(),
|
|
253
|
+
role: "user",
|
|
254
|
+
parts: payload.parts,
|
|
255
|
+
metadata: {
|
|
256
|
+
parent_id: parent_id,
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
261
|
+
setMessages((prev) => [...prev, message]);
|
|
262
|
+
send({
|
|
263
|
+
type: "run",
|
|
264
|
+
data: {
|
|
265
|
+
id: chatId,
|
|
266
|
+
message: {
|
|
267
|
+
id: message.id,
|
|
268
|
+
role: message.role,
|
|
269
|
+
parts: message.parts,
|
|
270
|
+
metadata: message.metadata,
|
|
271
|
+
},
|
|
272
|
+
vaultItems: payload.vaultItems ?? [],
|
|
273
|
+
timezone,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
[chatId, ensureConnected, messages, send, status],
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const stop = useCallback(async () => {
|
|
281
|
+
if (!job) return;
|
|
282
|
+
const connected = await ensureConnected();
|
|
283
|
+
if (!connected) return;
|
|
284
|
+
send({ type: "cancel", data: { job_id: job } });
|
|
285
|
+
}, [job, ensureConnected, send]);
|
|
286
|
+
|
|
287
|
+
const injectMessage = useCallback(
|
|
288
|
+
async (text: string): Promise<void> => {
|
|
289
|
+
if (!job) return;
|
|
290
|
+
|
|
291
|
+
const connected = await ensureConnected();
|
|
292
|
+
if (!connected) return;
|
|
293
|
+
|
|
294
|
+
const message_id = crypto.randomUUID();
|
|
295
|
+
|
|
296
|
+
// Optimistically add the user message to state
|
|
297
|
+
setMessages((prev) => [
|
|
298
|
+
...prev,
|
|
299
|
+
{
|
|
300
|
+
id: message_id,
|
|
301
|
+
role: "user",
|
|
302
|
+
parts: [{ type: "text", text }],
|
|
303
|
+
},
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
send({
|
|
307
|
+
type: "inject_message",
|
|
308
|
+
data: {
|
|
309
|
+
job_id: job,
|
|
310
|
+
text,
|
|
311
|
+
message_id,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
[job, ensureConnected, send],
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const sendClarifyResponse = useCallback(
|
|
319
|
+
(answers: Record<string, string>) => {
|
|
320
|
+
if (!pendingClarify) return;
|
|
321
|
+
send({
|
|
322
|
+
type: "clarify_response",
|
|
323
|
+
data: {
|
|
324
|
+
job_id: pendingClarify.job_id,
|
|
325
|
+
requestId: pendingClarify.requestId,
|
|
326
|
+
answers,
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
setPendingClarify(null);
|
|
330
|
+
},
|
|
331
|
+
[pendingClarify, send],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const reset = useCallback(() => {
|
|
335
|
+
setMessages([]);
|
|
336
|
+
setStatus("ready");
|
|
337
|
+
setJob("");
|
|
338
|
+
setPendingClarify(null);
|
|
339
|
+
hasRequestedChat.current = false;
|
|
340
|
+
}, []);
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
messages,
|
|
344
|
+
setMessages,
|
|
345
|
+
sendMessage,
|
|
346
|
+
injectMessage,
|
|
347
|
+
status,
|
|
348
|
+
stop,
|
|
349
|
+
reset,
|
|
350
|
+
isConnected,
|
|
351
|
+
pendingClarify,
|
|
352
|
+
sendClarifyResponse,
|
|
353
|
+
};
|
|
354
|
+
}
|