@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/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
|
|
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.
|
|
70
|
-
.filter((p)
|
|
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` | `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
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
|
|
126
|
+
| `messages` | `ChatMessage[]` | Current messages with `processedParts` |
|
|
121
127
|
| `setMessages` | `SetState<ChatMessage[]>` | Replace the message list |
|
|
122
|
-
| `sendMessage` | `(payload) => Promise
|
|
123
|
-
| `injectMessage` | `(text) => Promise
|
|
124
|
-
| `status` | `ChatStatus` | `"ready"`
|
|
125
|
-
| `stop` | `() => Promise
|
|
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`
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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,
|
|
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 };
|