@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 CHANGED
@@ -1,13 +1,15 @@
1
1
  # @futurity/chat-react
2
2
 
3
- React hooks and utilities for embedding Futurity chat in any React application.
3
+ React hooks and utilities for embedding Futurity chat in any React application. For non-React apps, the framework-agnostic `WebSocketConnection` class is also exported.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install @futurity/chat-react @futurity/chat-protocol react zod
8
+ npm install @futurity/chat-react
9
9
  ```
10
10
 
11
+ Peer dependencies: `react` (^18 or ^19) and `zod` (^4). `@futurity/chat-protocol` is included as a direct dependency.
12
+
11
13
  ## Quick Start
12
14
 
13
15
  ```tsx
@@ -26,33 +28,6 @@ function Chat({ chatId, apiUrl }: { chatId: string; apiUrl: string }) {
26
28
  } = useStreamChat({
27
29
  chatId,
28
30
  wsUrl: `${apiUrl}/api/v2/chat/${chatId}`,
29
- onStart: (id) => {
30
- // A new assistant message is about to stream
31
- setMessages((prev) => [
32
- ...prev,
33
- { id, role: "assistant", parts: [{ type: "text", text: "" }] },
34
- ]);
35
- },
36
- onDelta: (id, delta, consolidated) => {
37
- // Append or replace the latest part
38
- setMessages((prev) =>
39
- prev.map((m) => {
40
- if (m.id !== id) return m;
41
- const parts = m.parts ?? [];
42
- return {
43
- ...m,
44
- parts: consolidated
45
- ? [...parts.slice(0, -1), delta]
46
- : [...parts, delta],
47
- };
48
- }),
49
- );
50
- },
51
- onResume: (id, parts) => {
52
- setMessages((prev) =>
53
- prev.map((m) => (m.id !== id ? m : { ...m, parts })),
54
- );
55
- },
56
31
  onHistory: (history) => {
57
32
  setMessages(history.initialPath);
58
33
  },
@@ -66,8 +41,12 @@ function Chat({ chatId, apiUrl }: { chatId: string; apiUrl: string }) {
66
41
  {messages.map((msg) => (
67
42
  <div key={msg.id}>
68
43
  <strong>{msg.role}:</strong>
69
- {msg.parts
70
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
44
+ {msg.processedParts
45
+ .filter((p) => p.type === "regular")
46
+ .map((p) => p.type === "regular" ? p.part : null)
47
+ .filter((p): p is { type: "text"; text: string } =>
48
+ typeof p === "object" && p !== null && "type" in p && p.type === "text"
49
+ )
71
50
  .map((p, i) => (
72
51
  <span key={i}>{p.text}</span>
73
52
  ))}
@@ -94,60 +73,148 @@ function Chat({ chatId, apiUrl }: { chatId: string; apiUrl: string }) {
94
73
  }
95
74
  ```
96
75
 
76
+ ## Stream Accumulation
77
+
78
+ `useStreamChat` handles all stream protocol internally. Messages returned in the `messages` array include a `processedParts` field that provides structured, pre-accumulated content:
79
+
80
+ - **Text streaming**: Raw `text-start`/`text-delta`/`text-end` chunks are assembled into complete `{ type: "text", text: "..." }` parts.
81
+ - **Tool calls**: Input deltas are accumulated and JSON-parsed incrementally. Output states are tracked through `input-streaming` → `input-available` → `output-available`.
82
+ - **Subagent groups**: `data-split`/`data-endsplit`/`data-subagent-part` markers are grouped into `split-group` processed parts with their inner content accumulated.
83
+ - **Inject handling**: `inject_ack` and `inject_split` server messages automatically create placeholder assistant messages so subsequent stream deltas land correctly.
84
+
85
+ Each `processedPart` is either:
86
+ - `{ type: "regular", part, originalIndex }` — a single accumulated part
87
+ - `{ type: "split-group", title, subtitle, parts, startIndex, endIndex, desktopSessionId }` — a group of subagent parts
88
+
89
+ Consumers no longer need `onDelta` or `onResume` callbacks — stream accumulation is handled internally.
90
+
91
+ ## Authentication
92
+
93
+ The WebSocket endpoint requires a valid access token. Pass it as a query parameter in the URL:
94
+
95
+ ```ts
96
+ useStreamChat({
97
+ chatId,
98
+ wsUrl: `wss://api.example.com/api/v2/chat/${chatId}?token=${accessToken}`,
99
+ // ...
100
+ });
101
+ ```
102
+
103
+ See the [external multi-tenant auth docs](https://github.com/futuritywork/api/blob/main/docs/external-multi-tenant-auth.md) for how to obtain tokens via OAuth 2.0 delegation.
104
+
97
105
  ## API
98
106
 
99
107
  ### `useStreamChat(options)`
100
108
 
101
- The main hook for connecting to a Futurity chat WebSocket.
109
+ The main hook for connecting to a Futurity chat WebSocket. Manages the full lifecycle: connection, history loading, streaming, cancellation, and clarification flows. Stream deltas are accumulated internally — messages include pre-computed `processedParts`.
102
110
 
103
111
  **Options:**
104
112
 
105
- | Option | Type | Description |
106
- | ---------- | ---------- | --------------------------------------------------------- |
107
- | `chatId` | `string` | The chat ID to connect to |
108
- | `wsUrl` | `string` | Full WebSocket URL (e.g. `wss://api.example.com/api/v2/chat/<id>`) |
109
- | `onStart` | `function` | Called when a new assistant message starts |
110
- | `onDelta` | `function` | Called on each stream delta |
111
- | `onResume` | `function` | Called when a stream resumes with accumulated parts |
112
- | `onFinish` | `function` | Called when streaming finishes |
113
- | `onError` | `function` | Called on protocol errors |
114
- | `onHistory`| `function` | Called when chat history is received |
113
+ | Option | Type | Required | Description |
114
+ | ---------- | ---------- | -------- | --------------------------------------------------------- |
115
+ | `chatId` | `string` | Yes | The chat ID to connect to |
116
+ | `wsUrl` | `string` | Yes | Full WebSocket URL (e.g. `wss://api.example.com/api/v2/chat/<id>`) |
117
+ | `onStart` | `(id: string) => void` | No | Called when a new assistant message starts streaming |
118
+ | `onFinish` | `() => void` | No | Called when streaming finishes |
119
+ | `onError` | `(error: { error?: string; message?: string }) => void` | No | Called on protocol errors |
120
+ | `onHistory`| `(history: TransformedHistory) => void` | No | Called when chat history is received. History includes `messages`, `tree`, `byId`, `initialPath`, and `activeMessageId` |
115
121
 
116
122
  **Returns:**
117
123
 
118
124
  | Property | Type | Description |
119
125
  | -------------------- | ----------------------- | -------------------------------------- |
120
- | `messages` | `ChatMessage[]` | Current messages in the active branch |
126
+ | `messages` | `ChatMessage[]` | Current messages with `processedParts` |
121
127
  | `setMessages` | `SetState<ChatMessage[]>` | Replace the message list |
122
- | `sendMessage` | `(payload) => Promise` | Send a new user message |
123
- | `injectMessage` | `(text) => Promise` | Inject text into an active stream |
124
- | `status` | `ChatStatus` | `"ready"` / `"submitted"` / `"streaming"` / `"error"` |
125
- | `stop` | `() => Promise` | Cancel the current generation |
128
+ | `sendMessage` | `(payload: SendMessagePayload) => Promise<void>` | Send a new user message |
129
+ | `injectMessage` | `(text: string) => Promise<void>` | Inject text into an active stream |
130
+ | `status` | `ChatStatus` | `"ready"` \| `"submitted"` \| `"streaming"` \| `"error"` |
131
+ | `stop` | `() => Promise<void>` | Cancel the current generation |
126
132
  | `reset` | `() => void` | Reset all chat state |
127
133
  | `isConnected` | `boolean` | WebSocket connection status |
128
134
  | `pendingClarify` | `object \| null` | Pending clarification request |
129
- | `sendClarifyResponse`| `(answers) => void` | Submit clarification answers |
135
+ | `sendClarifyResponse`| `(answers: Record<string, string>) => void` | Submit clarification answers |
130
136
 
131
137
  ### `useReconnectingWebSocket(options)`
132
138
 
133
- A generic WebSocket hook with automatic reconnection, heartbeat, and message queuing. Used internally by `useStreamChat`, but also available for custom WebSocket connections.
139
+ A generic WebSocket hook with automatic reconnection, heartbeat (ping/pong), exponential backoff, and message queuing while disconnected. Used internally by `useStreamChat`, but available for custom WebSocket connections.
140
+
141
+ ```ts
142
+ const { send, isConnected, ensureConnected, reconnect } =
143
+ useReconnectingWebSocket<ServerMsg, ClientCmd>({
144
+ url: "wss://api.example.com/ws",
145
+ onMessage: (msg) => console.log(msg),
146
+ enabled: true,
147
+ });
148
+ ```
149
+
150
+ **Options:** `url`, `onMessage`, `onConnectionChange`, `onError`, `enabled`, `heartbeatInterval` (default 5s), `heartbeatTimeout` (default 10s), `initialReconnectDelay` (default 1s), `maxReconnectDelay` (default 30s), `debugPrefix`.
151
+
152
+ **Returns:** `connectionState`, `isConnected`, `send()`, `ensureConnected()`, `reconnect()`.
153
+
154
+ ### `WebSocketConnection` (framework-agnostic)
155
+
156
+ The underlying WebSocket class with no React dependency. Use this for Vue, Svelte, vanilla JS, or server-side integrations.
157
+
158
+ ```ts
159
+ import { WebSocketConnection } from "@futurity/chat-react";
160
+
161
+ const ws = new WebSocketConnection({
162
+ url: "wss://api.example.com/api/v2/chat/abc123",
163
+ onMessage: (data) => {
164
+ // data is already JSON-parsed
165
+ console.log(data);
166
+ },
167
+ onConnectionChange: (state) => {
168
+ // "disconnected" | "connecting" | "connected"
169
+ console.log("Connection:", state);
170
+ },
171
+ });
172
+
173
+ ws.connect();
174
+ ws.send({ type: "get_chat", data: { chat_id: "abc123" } });
175
+
176
+ // Later:
177
+ ws.disconnect();
178
+ ```
179
+
180
+ Features: automatic reconnection with exponential backoff, heartbeat ping/pong, message queuing while disconnected, JSON serialization.
134
181
 
135
182
  ### Tree Utilities
136
183
 
137
- - `buildTree(messages, byId)` - Build a navigable message tree from a flat array
138
- - `findLatestPath(tree, byId)` - Find the latest conversation path in a tree
139
- - `MessageNode` - Tree node class with `getChildren()`, `parent_id`, and `message`
184
+ For branching conversation support (navigating conversation forks):
185
+
186
+ ```ts
187
+ import { buildTree, findLatestPath, MessageNode } from "@futurity/chat-react";
188
+
189
+ // Build a tree from flat messages (uses metadata.parent_id)
190
+ const byId = new Map<string, MessageNode>();
191
+ const roots = buildTree(messages, byId);
192
+
193
+ // Find the latest conversation branch
194
+ const latestPath = findLatestPath(roots, byId);
195
+ ```
140
196
 
141
197
  ### Protocol Types
142
198
 
143
- All chat WebSocket protocol types are re-exported from `@futurity/chat-protocol`:
199
+ All chat protocol types are re-exported from `@futurity/chat-protocol`:
144
200
 
145
201
  ```ts
146
202
  import type {
203
+ MessagePart,
147
204
  WsClientCommand,
148
205
  WsServerMessage,
149
206
  WsStreamMessage,
150
207
  WsClarifyQuestion,
151
208
  WsVaultItem,
209
+ ChatMessage,
210
+ ChatStatus,
211
+ StreamDelta,
212
+ SendMessagePayload,
213
+ ProcessedPart,
214
+ PreprocessorState,
152
215
  } from "@futurity/chat-react";
153
216
  ```
217
+
218
+ ## License
219
+
220
+ MIT
package/dist/index.d.ts CHANGED
@@ -11,6 +11,82 @@ import { z } from 'zod';
11
11
 
12
12
  declare function parseServerMessage(data: unknown): WsServerMessage | null;
13
13
 
14
+ type ProcessedPart = {
15
+ type: "regular";
16
+ part: MessagePart;
17
+ originalIndex: number;
18
+ } | {
19
+ type: "split-group";
20
+ title?: string;
21
+ subtitle?: string;
22
+ parts: MessagePart[];
23
+ startIndex: number;
24
+ endIndex: number;
25
+ desktopSessionId?: string;
26
+ };
27
+ /**
28
+ * Incrementally accumulates raw UI message stream protocol chunks into
29
+ * rendered message parts. Maintains internal state so repeated calls with a
30
+ * growing `rawParts` array only process newly appended chunks.
31
+ *
32
+ * Internally tracks mutable objects via `Chunk` maps (text-by-id, tool-by-id)
33
+ * and casts to `MessagePart` at the output boundary. The constructed shapes
34
+ * conform to the `MessagePart` union at runtime.
35
+ */
36
+ declare class StreamAccumulator {
37
+ readonly parts: MessagePart[];
38
+ private textById;
39
+ private reasoningById;
40
+ private toolById;
41
+ private partialToolText;
42
+ private processedCount;
43
+ private push;
44
+ /** Process any new raw chunks and return the full accumulated parts array. */
45
+ accumulate(rawParts: unknown[]): MessagePart[];
46
+ }
47
+ type PendingGroup = {
48
+ startIndex: number;
49
+ title?: string;
50
+ subtitle?: string;
51
+ desktopSessionId?: string;
52
+ subAgentId: string;
53
+ /** Raw stream protocol chunks from data-subagent-part messages (pre-accumulation). */
54
+ innerParts: unknown[];
55
+ endIndex: number | null;
56
+ };
57
+ type PreprocessorState = {
58
+ messageId: string | null;
59
+ /** How many raw parts pass 1 has already scanned. */
60
+ scannedLength: number;
61
+ /** Indices consumed by subAgentId-based groups. */
62
+ claimedIndices: Set<number>;
63
+ /** Groups still collecting inner parts (keyed by subAgentId). */
64
+ openGroups: Map<string, PendingGroup>;
65
+ /** All groups keyed by their startIndex (for pass 2 lookup). */
66
+ groupsByStartIndex: Map<number, PendingGroup>;
67
+ /** Reverse lookup: subAgentId → group (includes closed groups). */
68
+ groupBySubAgentId: Map<string, PendingGroup>;
69
+ /** Per-group stream accumulators (keyed by group startIndex). */
70
+ accumulators: Map<number, StreamAccumulator>;
71
+ };
72
+ declare function createPreprocessorState(messageId?: string): PreprocessorState;
73
+ /**
74
+ * Incrementally preprocess message parts to group subagent content.
75
+ *
76
+ * Pass 1 (incremental): only scans parts appended since the last call,
77
+ * building group data structures without re-parsing already-seen parts.
78
+ *
79
+ * Per-group accumulation (incremental): each group's stream protocol chunks
80
+ * are accumulated via a stateful {@link StreamAccumulator} that only processes
81
+ * new chunks on each call.
82
+ *
83
+ * Pass 2 (rebuild): constructs the output array from cached state. This is
84
+ * cheap — O(n) Map/Set lookups with no Zod parsing for subAgentId groups.
85
+ *
86
+ * Falls back to legacy greedy scanning for old messages without subAgentId.
87
+ */
88
+ declare function incrementalPreprocess(state: PreprocessorState, parts: MessagePart[]): ProcessedPart[];
89
+
14
90
  declare const messageMetadataSchema: z.ZodObject<{
15
91
  parent_id: z.ZodNullable<z.ZodUUID>;
16
92
  }, z.core.$strip>;
@@ -20,6 +96,8 @@ type ChatMessage = {
20
96
  id: string;
21
97
  role: "user" | "assistant" | "system";
22
98
  parts: MessagePart[];
99
+ /** Pre-computed processed parts with subagent grouping and stream accumulation. */
100
+ processedParts: ProcessedPart[];
23
101
  createdAt?: Date;
24
102
  metadata?: MessageMetadata;
25
103
  };
@@ -373,10 +451,6 @@ type UseStreamChatOptions = {
373
451
  wsUrl: string;
374
452
  /** Called when a new assistant message starts streaming. */
375
453
  onStart?: (id: string) => void;
376
- /** Called on each stream delta. */
377
- onDelta?: (id: string, delta: StreamDelta, consolidated: boolean) => void;
378
- /** Called when a stream resumes with accumulated parts. */
379
- onResume?: (id: string, parts: MessagePart[]) => void;
380
454
  /** Called when streaming finishes. */
381
455
  onFinish?: () => void;
382
456
  /** Called on a protocol error. */
@@ -409,6 +483,6 @@ type UseStreamChatReturn = {
409
483
  /** Submit answers to a clarification request. */
410
484
  sendClarifyResponse: (answers: Record<string, string>) => void;
411
485
  };
412
- declare function useStreamChat({ chatId, wsUrl, onStart, onDelta, onResume, onFinish, onError, onHistory, }: UseStreamChatOptions): UseStreamChatReturn;
486
+ declare function useStreamChat({ chatId, wsUrl, onStart, onFinish, onError, onHistory, }: UseStreamChatOptions): UseStreamChatReturn;
413
487
 
414
- export { type ChatMessage, type ChatStatus, type ClarifyData, type ConnectionState, type MessageMetadata, MessageNode, type SendMessageFn, type SendMessagePayload, type StreamDelta, type UseStreamChatOptions, type UseStreamChatReturn, WebSocketConnection, type WebSocketConnectionOptions, type WebSocketOptions, type WebSocketResult, Z_ChatMessage, buildTree, findLatestPath, messageMetadataSchema, parseServerMessage, useReconnectingWebSocket, useStreamChat };
488
+ export { type ChatMessage, type ChatStatus, type ClarifyData, type ConnectionState, type MessageMetadata, MessageNode, type PreprocessorState, type ProcessedPart, type SendMessageFn, type SendMessagePayload, StreamAccumulator, type StreamDelta, type UseStreamChatOptions, type UseStreamChatReturn, WebSocketConnection, type WebSocketConnectionOptions, type WebSocketOptions, type WebSocketResult, Z_ChatMessage, buildTree, createPreprocessorState, findLatestPath, incrementalPreprocess, messageMetadataSchema, parseServerMessage, useReconnectingWebSocket, useStreamChat };