@futurity/chat-react 0.0.2 → 0.1.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/README.md +120 -53
- package/dist/index.d.ts +80 -6
- package/dist/index.js +426 -12
- package/package.json +7 -3
- package/src/index.ts +7 -0
- package/src/stream-accumulator.ts +448 -0
- package/src/types.ts +3 -0
- package/src/useStreamChat.ts +135 -23
package/src/useStreamChat.ts
CHANGED
|
@@ -4,13 +4,14 @@ import type {
|
|
|
4
4
|
} from "@futurity/chat-protocol";
|
|
5
5
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { type ClientCommand, parseServerMessage } from "./chat-protocol";
|
|
7
|
+
import {
|
|
8
|
+
createPreprocessorState,
|
|
9
|
+
incrementalPreprocess,
|
|
10
|
+
type PreprocessorState,
|
|
11
|
+
type ProcessedPart,
|
|
12
|
+
} from "./stream-accumulator";
|
|
7
13
|
import { buildTree, findLatestPath, type MessageNode } from "./tree-builder";
|
|
8
|
-
import type {
|
|
9
|
-
ChatMessage,
|
|
10
|
-
ChatStatus,
|
|
11
|
-
SendMessagePayload,
|
|
12
|
-
StreamDelta,
|
|
13
|
-
} from "./types";
|
|
14
|
+
import type { ChatMessage, ChatStatus, SendMessagePayload } from "./types";
|
|
14
15
|
import { Z_ChatMessage } from "./types";
|
|
15
16
|
import { useReconnectingWebSocket } from "./useReconnectingWebSocket";
|
|
16
17
|
|
|
@@ -22,12 +23,49 @@ type TransformedHistory = {
|
|
|
22
23
|
activeMessageId?: string;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
/** Produce simple 1:1 processedParts for a non-assistant message. */
|
|
27
|
+
function simpleProcessedParts(parts: MessagePart[]): ProcessedPart[] {
|
|
28
|
+
return parts.map((part, i) => ({
|
|
29
|
+
type: "regular" as const,
|
|
30
|
+
part,
|
|
31
|
+
originalIndex: i,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Run the preprocessor on a message and return it with processedParts populated. */
|
|
36
|
+
function preprocessMessage(
|
|
37
|
+
msg: Omit<ChatMessage, "processedParts"> & {
|
|
38
|
+
processedParts?: ProcessedPart[];
|
|
39
|
+
},
|
|
40
|
+
statesMap: Map<string, PreprocessorState>,
|
|
41
|
+
): ChatMessage {
|
|
42
|
+
if (msg.role !== "assistant") {
|
|
43
|
+
return { ...msg, processedParts: simpleProcessedParts(msg.parts) };
|
|
44
|
+
}
|
|
45
|
+
let state = statesMap.get(msg.id);
|
|
46
|
+
if (!state || state.scannedLength > (msg.parts?.length ?? 0)) {
|
|
47
|
+
// Reset if message changed (parts shrunk) or no state yet
|
|
48
|
+
state = createPreprocessorState(msg.id);
|
|
49
|
+
statesMap.set(msg.id, state);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
...msg,
|
|
53
|
+
processedParts: incrementalPreprocess(state, msg.parts ?? []),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function transformChatHistory(
|
|
58
|
+
rawMessages: unknown[],
|
|
59
|
+
statesMap: Map<string, PreprocessorState>,
|
|
60
|
+
): TransformedHistory {
|
|
26
61
|
const parsedMessages = Z_ChatMessage.array().safeParse(rawMessages);
|
|
27
62
|
if (!parsedMessages.success) {
|
|
28
63
|
throw new Error("Invalid chat history");
|
|
29
64
|
}
|
|
30
|
-
|
|
65
|
+
// Parsed messages don't have processedParts yet — add them
|
|
66
|
+
const messages = parsedMessages.data.map((msg) =>
|
|
67
|
+
preprocessMessage(msg, statesMap),
|
|
68
|
+
);
|
|
31
69
|
const byId = new Map<string, MessageNode>();
|
|
32
70
|
const tree = buildTree(messages, byId);
|
|
33
71
|
const initialPath = findLatestPath(tree, byId);
|
|
@@ -47,10 +85,6 @@ export type UseStreamChatOptions = {
|
|
|
47
85
|
wsUrl: string;
|
|
48
86
|
/** Called when a new assistant message starts streaming. */
|
|
49
87
|
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
88
|
/** Called when streaming finishes. */
|
|
55
89
|
onFinish?: () => void;
|
|
56
90
|
/** Called on a protocol error. */
|
|
@@ -86,8 +120,6 @@ export function useStreamChat({
|
|
|
86
120
|
chatId,
|
|
87
121
|
wsUrl,
|
|
88
122
|
onStart,
|
|
89
|
-
onDelta,
|
|
90
|
-
onResume,
|
|
91
123
|
onFinish,
|
|
92
124
|
onError,
|
|
93
125
|
onHistory,
|
|
@@ -107,9 +139,10 @@ export function useStreamChat({
|
|
|
107
139
|
WsClarifyRequestMessage["data"] | null
|
|
108
140
|
>(null);
|
|
109
141
|
|
|
142
|
+
// Per-message preprocessor states, keyed by message ID
|
|
143
|
+
const preprocessorStatesRef = useRef(new Map<string, PreprocessorState>());
|
|
144
|
+
|
|
110
145
|
const onStartRef = useRef(onStart);
|
|
111
|
-
const onDeltaRef = useRef(onDelta);
|
|
112
|
-
const onResumeRef = useRef(onResume);
|
|
113
146
|
const onFinishRef = useRef(onFinish);
|
|
114
147
|
const onErrorRef = useRef(onError);
|
|
115
148
|
const onHistoryRef = useRef(onHistory);
|
|
@@ -117,8 +150,6 @@ export function useStreamChat({
|
|
|
117
150
|
|
|
118
151
|
useEffect(() => {
|
|
119
152
|
onStartRef.current = onStart;
|
|
120
|
-
onDeltaRef.current = onDelta;
|
|
121
|
-
onResumeRef.current = onResume;
|
|
122
153
|
onFinishRef.current = onFinish;
|
|
123
154
|
onErrorRef.current = onError;
|
|
124
155
|
onHistoryRef.current = onHistory;
|
|
@@ -155,16 +186,90 @@ export function useStreamChat({
|
|
|
155
186
|
|
|
156
187
|
case "stream": {
|
|
157
188
|
const messageId = message.messageId;
|
|
189
|
+
const delta = message.delta;
|
|
190
|
+
const consolidated = message.consolidated;
|
|
158
191
|
|
|
159
192
|
setStatus("streaming");
|
|
160
|
-
|
|
193
|
+
setMessages((prev) => {
|
|
194
|
+
const states = preprocessorStatesRef.current;
|
|
195
|
+
const exists = prev.some((m) => m.id === messageId);
|
|
196
|
+
let updated: ChatMessage[];
|
|
197
|
+
if (!exists) {
|
|
198
|
+
// Unknown message ID (e.g. new assistant message from inject) — create it
|
|
199
|
+
const newMsg: ChatMessage = {
|
|
200
|
+
id: messageId,
|
|
201
|
+
role: "assistant",
|
|
202
|
+
parts: [delta],
|
|
203
|
+
processedParts: [],
|
|
204
|
+
};
|
|
205
|
+
updated = [...prev, newMsg];
|
|
206
|
+
} else {
|
|
207
|
+
updated = prev.map((m) => {
|
|
208
|
+
if (m.id !== messageId) return m;
|
|
209
|
+
const parts = m.parts ?? [];
|
|
210
|
+
const nextParts = consolidated
|
|
211
|
+
? [...parts.slice(0, -1), delta]
|
|
212
|
+
: [...parts, delta];
|
|
213
|
+
return { ...m, parts: nextParts };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// Reprocess the affected message
|
|
217
|
+
return updated.map((m) =>
|
|
218
|
+
m.id === messageId ? preprocessMessage(m, states) : m,
|
|
219
|
+
);
|
|
220
|
+
});
|
|
161
221
|
break;
|
|
162
222
|
}
|
|
163
223
|
|
|
164
224
|
case "stream_resume": {
|
|
165
225
|
const messageId = message.messageId;
|
|
166
226
|
setStatus("streaming");
|
|
167
|
-
|
|
227
|
+
setMessages((prev) => {
|
|
228
|
+
const states = preprocessorStatesRef.current;
|
|
229
|
+
// Reset preprocessor state for this message since parts are being replaced
|
|
230
|
+
states.delete(messageId);
|
|
231
|
+
return prev.map((m) => {
|
|
232
|
+
if (m.id !== messageId) return m;
|
|
233
|
+
const updated = { ...m, parts: message.parts };
|
|
234
|
+
return preprocessMessage(updated, states);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case "inject_ack": {
|
|
241
|
+
const { new_assistant_id } = message.data;
|
|
242
|
+
setMessages((prev) => {
|
|
243
|
+
const exists = prev.some((m) => m.id === new_assistant_id);
|
|
244
|
+
if (exists) return prev;
|
|
245
|
+
return [
|
|
246
|
+
...prev,
|
|
247
|
+
{
|
|
248
|
+
id: new_assistant_id,
|
|
249
|
+
role: "assistant" as const,
|
|
250
|
+
parts: [],
|
|
251
|
+
processedParts: [],
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
});
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case "inject_split": {
|
|
259
|
+
const { new_message_id } = message.data;
|
|
260
|
+
setMessages((prev) => {
|
|
261
|
+
const exists = prev.some((m) => m.id === new_message_id);
|
|
262
|
+
if (exists) return prev;
|
|
263
|
+
return [
|
|
264
|
+
...prev,
|
|
265
|
+
{
|
|
266
|
+
id: new_message_id,
|
|
267
|
+
role: "assistant" as const,
|
|
268
|
+
parts: [],
|
|
269
|
+
processedParts: [],
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
});
|
|
168
273
|
break;
|
|
169
274
|
}
|
|
170
275
|
|
|
@@ -182,7 +287,10 @@ export function useStreamChat({
|
|
|
182
287
|
}
|
|
183
288
|
|
|
184
289
|
case "chat_history": {
|
|
185
|
-
const result = transformChatHistory(
|
|
290
|
+
const result = transformChatHistory(
|
|
291
|
+
message.data.messages,
|
|
292
|
+
preprocessorStatesRef.current,
|
|
293
|
+
);
|
|
186
294
|
result.activeMessageId = message.data.activeMessageId;
|
|
187
295
|
onHistoryRef.current?.(result);
|
|
188
296
|
break;
|
|
@@ -252,6 +360,7 @@ export function useStreamChat({
|
|
|
252
360
|
id: crypto.randomUUID(),
|
|
253
361
|
role: "user",
|
|
254
362
|
parts: payload.parts,
|
|
363
|
+
processedParts: simpleProcessedParts(payload.parts),
|
|
255
364
|
metadata: {
|
|
256
365
|
parent_id: parent_id,
|
|
257
366
|
},
|
|
@@ -292,14 +401,16 @@ export function useStreamChat({
|
|
|
292
401
|
if (!connected) return;
|
|
293
402
|
|
|
294
403
|
const message_id = crypto.randomUUID();
|
|
404
|
+
const parts = [{ type: "text" as const, text }];
|
|
295
405
|
|
|
296
406
|
// Optimistically add the user message to state
|
|
297
407
|
setMessages((prev) => [
|
|
298
408
|
...prev,
|
|
299
409
|
{
|
|
300
410
|
id: message_id,
|
|
301
|
-
role: "user",
|
|
302
|
-
parts
|
|
411
|
+
role: "user" as const,
|
|
412
|
+
parts,
|
|
413
|
+
processedParts: simpleProcessedParts(parts),
|
|
303
414
|
},
|
|
304
415
|
]);
|
|
305
416
|
|
|
@@ -337,6 +448,7 @@ export function useStreamChat({
|
|
|
337
448
|
setJob("");
|
|
338
449
|
setPendingClarify(null);
|
|
339
450
|
hasRequestedChat.current = false;
|
|
451
|
+
preprocessorStatesRef.current.clear();
|
|
340
452
|
}, []);
|
|
341
453
|
|
|
342
454
|
return {
|