@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.
@@ -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
- function transformChatHistory(rawMessages: unknown[]): TransformedHistory {
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
- const messages = parsedMessages.data;
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
- onDeltaRef.current?.(messageId, message.delta, message.consolidated);
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
- onResumeRef.current?.(messageId, message.parts);
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(message.data.messages);
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: [{ type: "text", text }],
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 {