@deltakit/react 0.2.0 → 0.2.3
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 +116 -4
- package/dist/index.cjs +587 -101
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -6
- package/dist/index.d.ts +151 -6
- package/dist/index.js +588 -102
- package/dist/index.js.map +1 -1
- package/package.json +56 -49
package/dist/index.js
CHANGED
|
@@ -18,25 +18,70 @@ function useAutoScroll(dependencies, options) {
|
|
|
18
18
|
const isAtBottomRef = useRef(true);
|
|
19
19
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
20
20
|
const rafRef = useRef(null);
|
|
21
|
+
const smoothRafRef = useRef(null);
|
|
22
|
+
const smoothTargetRef = useRef(null);
|
|
23
|
+
const lastAutoScrollHeightRef = useRef(null);
|
|
24
|
+
const cancelSmoothScroll = useCallback(() => {
|
|
25
|
+
if (smoothRafRef.current != null) {
|
|
26
|
+
cancelAnimationFrame(smoothRafRef.current);
|
|
27
|
+
smoothRafRef.current = null;
|
|
28
|
+
}
|
|
29
|
+
smoothTargetRef.current = null;
|
|
30
|
+
}, []);
|
|
31
|
+
const runSmoothScroll = useCallback(() => {
|
|
32
|
+
if (smoothRafRef.current != null) return;
|
|
33
|
+
const tick = () => {
|
|
34
|
+
const el = ref.current;
|
|
35
|
+
if (!el || !isAtBottomRef.current) {
|
|
36
|
+
cancelSmoothScroll();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const target = smoothTargetRef.current ?? el.scrollHeight;
|
|
40
|
+
const delta = target - el.scrollTop;
|
|
41
|
+
if (delta <= 1) {
|
|
42
|
+
el.scrollTop = target;
|
|
43
|
+
lastAutoScrollHeightRef.current = target;
|
|
44
|
+
smoothRafRef.current = null;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const step = Math.min(Math.max(delta * 0.35, 12), 120);
|
|
48
|
+
el.scrollTop = Math.min(target, el.scrollTop + step);
|
|
49
|
+
smoothRafRef.current = requestAnimationFrame(tick);
|
|
50
|
+
};
|
|
51
|
+
smoothRafRef.current = requestAnimationFrame(tick);
|
|
52
|
+
}, [cancelSmoothScroll]);
|
|
21
53
|
const scheduleScroll = useCallback(() => {
|
|
22
54
|
if (rafRef.current != null) return;
|
|
23
55
|
rafRef.current = requestAnimationFrame(() => {
|
|
24
56
|
rafRef.current = null;
|
|
25
57
|
const el = ref.current;
|
|
26
|
-
if (el
|
|
27
|
-
|
|
58
|
+
if (!el || !isAtBottomRef.current) return;
|
|
59
|
+
const nextHeight = el.scrollHeight;
|
|
60
|
+
if (lastAutoScrollHeightRef.current === nextHeight) return;
|
|
61
|
+
lastAutoScrollHeightRef.current = nextHeight;
|
|
62
|
+
if (behavior === "smooth") {
|
|
63
|
+
smoothTargetRef.current = nextHeight;
|
|
64
|
+
runSmoothScroll();
|
|
65
|
+
return;
|
|
28
66
|
}
|
|
67
|
+
cancelSmoothScroll();
|
|
68
|
+
el.scrollTop = nextHeight;
|
|
29
69
|
});
|
|
30
|
-
}, [behavior]);
|
|
70
|
+
}, [behavior, cancelSmoothScroll, runSmoothScroll]);
|
|
31
71
|
useEffect(() => {
|
|
32
72
|
const el = ref.current;
|
|
33
73
|
if (!el || !enabled) return;
|
|
34
74
|
const handleScroll = () => {
|
|
35
75
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold;
|
|
36
76
|
isAtBottomRef.current = atBottom;
|
|
77
|
+
if (!atBottom) {
|
|
78
|
+
lastAutoScrollHeightRef.current = null;
|
|
79
|
+
cancelSmoothScroll();
|
|
80
|
+
}
|
|
37
81
|
setIsAtBottom((prev) => prev === atBottom ? prev : atBottom);
|
|
38
82
|
};
|
|
39
83
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
|
84
|
+
handleScroll();
|
|
40
85
|
return () => el.removeEventListener("scroll", handleScroll);
|
|
41
86
|
}, [enabled, threshold]);
|
|
42
87
|
useEffect(() => {
|
|
@@ -72,21 +117,25 @@ function useAutoScroll(dependencies, options) {
|
|
|
72
117
|
cancelAnimationFrame(rafRef.current);
|
|
73
118
|
rafRef.current = null;
|
|
74
119
|
}
|
|
120
|
+
cancelSmoothScroll();
|
|
75
121
|
};
|
|
76
|
-
}, []);
|
|
122
|
+
}, [cancelSmoothScroll]);
|
|
77
123
|
const scrollToBottom = useCallback(() => {
|
|
78
124
|
const el = ref.current;
|
|
79
125
|
if (!el) return;
|
|
126
|
+
cancelSmoothScroll();
|
|
80
127
|
isAtBottomRef.current = true;
|
|
128
|
+
lastAutoScrollHeightRef.current = el.scrollHeight;
|
|
81
129
|
setIsAtBottom(true);
|
|
82
130
|
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
83
|
-
}, [behavior]);
|
|
131
|
+
}, [behavior, cancelSmoothScroll]);
|
|
84
132
|
return { ref, scrollToBottom, isAtBottom };
|
|
85
133
|
}
|
|
86
134
|
|
|
87
135
|
// src/use-stream-chat.ts
|
|
88
|
-
import {
|
|
89
|
-
|
|
136
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
|
|
137
|
+
|
|
138
|
+
// src/chat-controller.ts
|
|
90
139
|
var counter = 0;
|
|
91
140
|
function generateId() {
|
|
92
141
|
return `msg_${Date.now()}_${++counter}`;
|
|
@@ -94,6 +143,385 @@ function generateId() {
|
|
|
94
143
|
function createMessage(role, parts) {
|
|
95
144
|
return { id: generateId(), role, parts };
|
|
96
145
|
}
|
|
146
|
+
function createChatTransportContext(options) {
|
|
147
|
+
const helpers = {
|
|
148
|
+
appendPart: options.appendPart,
|
|
149
|
+
appendText: options.appendText,
|
|
150
|
+
setMessages: options.setMessages
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
emit: (event) => {
|
|
154
|
+
options.eventHandler(event, helpers);
|
|
155
|
+
},
|
|
156
|
+
ensureAssistantMessage: () => {
|
|
157
|
+
options.setMessages((prev) => {
|
|
158
|
+
const last = prev[prev.length - 1];
|
|
159
|
+
if (last?.role === "assistant") {
|
|
160
|
+
return prev;
|
|
161
|
+
}
|
|
162
|
+
return [...prev, createMessage("assistant", [])];
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
fail: (error) => {
|
|
166
|
+
options.setError(error);
|
|
167
|
+
options.onError?.(error);
|
|
168
|
+
options.setIsLoading(false);
|
|
169
|
+
options.setRunId(null);
|
|
170
|
+
},
|
|
171
|
+
finish: () => {
|
|
172
|
+
options.setIsLoading(false);
|
|
173
|
+
options.setRunId(null);
|
|
174
|
+
const finalMessages = options.getMessages();
|
|
175
|
+
const lastMessage = finalMessages[finalMessages.length - 1];
|
|
176
|
+
if (lastMessage?.role === "assistant") {
|
|
177
|
+
options.onMessage?.(lastMessage);
|
|
178
|
+
}
|
|
179
|
+
options.onFinish?.(finalMessages);
|
|
180
|
+
},
|
|
181
|
+
getMessages: options.getMessages,
|
|
182
|
+
setRunId: (runId) => {
|
|
183
|
+
options.setRunId(runId);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/transports.ts
|
|
189
|
+
import { parseSSEStream } from "@deltakit/core";
|
|
190
|
+
function toError(error) {
|
|
191
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
192
|
+
}
|
|
193
|
+
function isAbortError(error) {
|
|
194
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
195
|
+
}
|
|
196
|
+
function resolveRunId(response) {
|
|
197
|
+
if (!response || typeof response !== "object") {
|
|
198
|
+
throw new Error("Background SSE start response did not contain a run id");
|
|
199
|
+
}
|
|
200
|
+
const maybeRunId = "runId" in response ? response.runId : "job_id" in response ? response.job_id : null;
|
|
201
|
+
if (typeof maybeRunId !== "string" || maybeRunId.length === 0) {
|
|
202
|
+
throw new Error("Background SSE start response did not contain a run id");
|
|
203
|
+
}
|
|
204
|
+
return maybeRunId;
|
|
205
|
+
}
|
|
206
|
+
function resolveUrl(url, runId) {
|
|
207
|
+
return typeof url === "function" ? url(runId) : url.replace(":runId", runId);
|
|
208
|
+
}
|
|
209
|
+
async function streamFetchSSE(response, context, signal) {
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`SSE request failed: ${response.status} ${response.statusText}`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (!response.body) {
|
|
216
|
+
throw new Error("Response body is null \u2014 SSE streaming not supported");
|
|
217
|
+
}
|
|
218
|
+
for await (const event of parseSSEStream(response.body, signal)) {
|
|
219
|
+
context.emit(event);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function createDirectSSETransport(config) {
|
|
223
|
+
return {
|
|
224
|
+
start: ({ context, message }) => {
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const run = {
|
|
227
|
+
close: () => {
|
|
228
|
+
controller.abort();
|
|
229
|
+
},
|
|
230
|
+
stop: () => {
|
|
231
|
+
controller.abort();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
235
|
+
void (async () => {
|
|
236
|
+
try {
|
|
237
|
+
const response = await fetchImpl(config.api, {
|
|
238
|
+
body: JSON.stringify({ message, ...config.body }),
|
|
239
|
+
headers: {
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
...config.headers
|
|
242
|
+
},
|
|
243
|
+
method: config.method ?? "POST",
|
|
244
|
+
signal: controller.signal
|
|
245
|
+
});
|
|
246
|
+
await streamFetchSSE(response, context, controller.signal);
|
|
247
|
+
context.finish();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (!isAbortError(error)) {
|
|
250
|
+
context.fail(toError(error));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
return run;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function createBackgroundSSETransport(config) {
|
|
259
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
260
|
+
const connect = (runId, context) => {
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
void (async () => {
|
|
263
|
+
try {
|
|
264
|
+
context.ensureAssistantMessage();
|
|
265
|
+
const response = await fetchImpl(resolveUrl(config.eventsApi, runId), {
|
|
266
|
+
headers: config.eventHeaders,
|
|
267
|
+
method: "GET",
|
|
268
|
+
signal: controller.signal
|
|
269
|
+
});
|
|
270
|
+
await streamFetchSSE(response, context, controller.signal);
|
|
271
|
+
context.finish();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (!isAbortError(error)) {
|
|
274
|
+
context.fail(toError(error));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
})();
|
|
278
|
+
return {
|
|
279
|
+
close: () => {
|
|
280
|
+
controller.abort();
|
|
281
|
+
},
|
|
282
|
+
stop: () => {
|
|
283
|
+
controller.abort();
|
|
284
|
+
if (config.cancelApi) {
|
|
285
|
+
void fetchImpl(resolveUrl(config.cancelApi, runId), {
|
|
286
|
+
method: "POST"
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
runId
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
return {
|
|
294
|
+
resume: ({ context, runId }) => {
|
|
295
|
+
context.setRunId(runId);
|
|
296
|
+
return connect(runId, context);
|
|
297
|
+
},
|
|
298
|
+
start: ({ context, message }) => {
|
|
299
|
+
const startController = new AbortController();
|
|
300
|
+
let activeRun;
|
|
301
|
+
void (async () => {
|
|
302
|
+
try {
|
|
303
|
+
const response = await fetchImpl(config.startApi, {
|
|
304
|
+
body: JSON.stringify({ message, ...config.startBody }),
|
|
305
|
+
headers: {
|
|
306
|
+
"Content-Type": "application/json",
|
|
307
|
+
...config.startHeaders
|
|
308
|
+
},
|
|
309
|
+
method: config.startMethod ?? "POST",
|
|
310
|
+
signal: startController.signal
|
|
311
|
+
});
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Background SSE start failed: ${response.status} ${response.statusText}`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const data = await response.json();
|
|
318
|
+
const runId = (config.resolveRunId ?? resolveRunId)(data);
|
|
319
|
+
context.setRunId(runId);
|
|
320
|
+
activeRun = connect(runId, context);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (!isAbortError(error)) {
|
|
323
|
+
context.fail(toError(error));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
327
|
+
return {
|
|
328
|
+
close: () => {
|
|
329
|
+
startController.abort();
|
|
330
|
+
void activeRun?.close?.();
|
|
331
|
+
},
|
|
332
|
+
stop: () => {
|
|
333
|
+
startController.abort();
|
|
334
|
+
activeRun?.stop?.();
|
|
335
|
+
},
|
|
336
|
+
runId: null
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function defaultParseWebSocketMessage(data) {
|
|
342
|
+
if (typeof data !== "string") {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const parsed = JSON.parse(data);
|
|
346
|
+
return parsed;
|
|
347
|
+
}
|
|
348
|
+
function createWebSocketTransport(config) {
|
|
349
|
+
const parseMessage = config.parseMessage ?? defaultParseWebSocketMessage;
|
|
350
|
+
const serializeMessage = config.serializeMessage ?? JSON.stringify;
|
|
351
|
+
let resolvedRunId = null;
|
|
352
|
+
const applyIncomingEvents = (parsed, context) => {
|
|
353
|
+
const events = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
|
354
|
+
for (const item of events) {
|
|
355
|
+
const nextRunId = config.resolveRunId?.(item) ?? null;
|
|
356
|
+
if (nextRunId) {
|
|
357
|
+
resolvedRunId = nextRunId;
|
|
358
|
+
context.setRunId(nextRunId);
|
|
359
|
+
}
|
|
360
|
+
context.emit(item);
|
|
361
|
+
if (item.type === "done") {
|
|
362
|
+
context.finish();
|
|
363
|
+
} else if (item.type === "error") {
|
|
364
|
+
context.fail(
|
|
365
|
+
new Error(
|
|
366
|
+
"message" in item && typeof item.message === "string" ? item.message : "Stream error"
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
return {
|
|
373
|
+
resume: ({ context, runId }) => {
|
|
374
|
+
context.setRunId(runId);
|
|
375
|
+
context.ensureAssistantMessage();
|
|
376
|
+
const socket = new WebSocket(
|
|
377
|
+
typeof config.url === "function" ? config.url(runId) : config.url,
|
|
378
|
+
config.protocols
|
|
379
|
+
);
|
|
380
|
+
let manuallyClosed = false;
|
|
381
|
+
let sentResumePayload = false;
|
|
382
|
+
const sendResumePayload = () => {
|
|
383
|
+
if (sentResumePayload || socket.readyState !== WebSocket.OPEN) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
sentResumePayload = true;
|
|
387
|
+
const payload = config.buildResumePayload?.(runId) ?? { [config.runIdKey ?? "runId"]: runId };
|
|
388
|
+
socket.send(serializeMessage(payload));
|
|
389
|
+
};
|
|
390
|
+
socket.onopen = sendResumePayload;
|
|
391
|
+
socket.onmessage = (event) => {
|
|
392
|
+
try {
|
|
393
|
+
applyIncomingEvents(parseMessage(event.data), context);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
context.fail(toError(error));
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
socket.onerror = () => {
|
|
399
|
+
context.fail(new Error("WebSocket connection failed"));
|
|
400
|
+
};
|
|
401
|
+
socket.onclose = () => {
|
|
402
|
+
if (!manuallyClosed) {
|
|
403
|
+
context.finish();
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
queueMicrotask(sendResumePayload);
|
|
407
|
+
return {
|
|
408
|
+
close: () => {
|
|
409
|
+
manuallyClosed = true;
|
|
410
|
+
socket.close();
|
|
411
|
+
},
|
|
412
|
+
stop: () => {
|
|
413
|
+
manuallyClosed = true;
|
|
414
|
+
const stopRunId = resolvedRunId ?? runId;
|
|
415
|
+
if (stopRunId && config.cancelUrl) {
|
|
416
|
+
void fetch(resolveUrl(config.cancelUrl, stopRunId), {
|
|
417
|
+
method: "POST"
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
socket.close();
|
|
421
|
+
},
|
|
422
|
+
runId
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
start: ({ context, message }) => {
|
|
426
|
+
const runId = config.runId ?? config.getResumeKey?.() ?? null;
|
|
427
|
+
const socket = new WebSocket(
|
|
428
|
+
typeof config.url === "function" ? config.url(runId) : config.url,
|
|
429
|
+
config.protocols
|
|
430
|
+
);
|
|
431
|
+
let manuallyClosed = false;
|
|
432
|
+
let sentStartPayload = false;
|
|
433
|
+
const sendStartPayload = () => {
|
|
434
|
+
if (sentStartPayload || socket.readyState !== WebSocket.OPEN) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
sentStartPayload = true;
|
|
438
|
+
context.ensureAssistantMessage();
|
|
439
|
+
const payload = {
|
|
440
|
+
message,
|
|
441
|
+
...config.body
|
|
442
|
+
};
|
|
443
|
+
if (runId) {
|
|
444
|
+
payload[config.runIdKey ?? "runId"] = runId;
|
|
445
|
+
}
|
|
446
|
+
socket.send(serializeMessage(payload));
|
|
447
|
+
};
|
|
448
|
+
socket.onopen = sendStartPayload;
|
|
449
|
+
socket.onmessage = (event) => {
|
|
450
|
+
try {
|
|
451
|
+
applyIncomingEvents(parseMessage(event.data), context);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
context.fail(toError(error));
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
socket.onerror = () => {
|
|
457
|
+
context.fail(new Error("WebSocket connection failed"));
|
|
458
|
+
};
|
|
459
|
+
socket.onclose = () => {
|
|
460
|
+
if (!manuallyClosed) {
|
|
461
|
+
context.finish();
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
queueMicrotask(sendStartPayload);
|
|
465
|
+
return {
|
|
466
|
+
close: () => {
|
|
467
|
+
manuallyClosed = true;
|
|
468
|
+
socket.close();
|
|
469
|
+
},
|
|
470
|
+
stop: () => {
|
|
471
|
+
manuallyClosed = true;
|
|
472
|
+
const stopRunId = resolvedRunId ?? runId;
|
|
473
|
+
if (stopRunId && config.cancelUrl) {
|
|
474
|
+
void fetch(resolveUrl(config.cancelUrl, stopRunId), {
|
|
475
|
+
method: "POST"
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
socket.close();
|
|
479
|
+
},
|
|
480
|
+
runId
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function resolveTransport(options) {
|
|
486
|
+
if (typeof options.transport === "object" && options.transport) {
|
|
487
|
+
return options.transport;
|
|
488
|
+
}
|
|
489
|
+
const transportKind = options.transport ?? "sse";
|
|
490
|
+
if (transportKind === "background-sse") {
|
|
491
|
+
const config = options.transportOptions?.backgroundSSE;
|
|
492
|
+
if (!config) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
'`transportOptions.backgroundSSE` is required when transport is "background-sse"'
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return createBackgroundSSETransport(config);
|
|
498
|
+
}
|
|
499
|
+
if (transportKind === "websocket") {
|
|
500
|
+
const config = options.transportOptions?.websocket;
|
|
501
|
+
if (!config) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
'`transportOptions.websocket` is required when transport is "websocket"'
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
return createWebSocketTransport(config);
|
|
507
|
+
}
|
|
508
|
+
const sseConfig = options.transportOptions?.sse ?? {
|
|
509
|
+
api: options.api,
|
|
510
|
+
body: options.body,
|
|
511
|
+
headers: options.headers
|
|
512
|
+
};
|
|
513
|
+
if (!sseConfig.api) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
"`api` or `transportOptions.sse.api` is required when using the default SSE transport"
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return createDirectSSETransport({
|
|
519
|
+
...sseConfig,
|
|
520
|
+
api: sseConfig.api
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/use-stream-chat.ts
|
|
97
525
|
function defaultOnEvent(event, helpers) {
|
|
98
526
|
if (event.type === "text_delta") {
|
|
99
527
|
helpers.appendText(event.delta);
|
|
@@ -101,25 +529,45 @@ function defaultOnEvent(event, helpers) {
|
|
|
101
529
|
}
|
|
102
530
|
function useStreamChat(options) {
|
|
103
531
|
const {
|
|
104
|
-
api,
|
|
105
|
-
headers,
|
|
106
|
-
body,
|
|
107
532
|
initialMessages,
|
|
108
533
|
onEvent,
|
|
109
534
|
onMessage,
|
|
110
535
|
onError,
|
|
111
|
-
onFinish
|
|
536
|
+
onFinish,
|
|
537
|
+
textBatchMs = 0
|
|
112
538
|
} = options;
|
|
113
|
-
const [messages, setMessages] = useState2(
|
|
114
|
-
initialMessages ?? []
|
|
115
|
-
);
|
|
539
|
+
const [messages, setMessages] = useState2(initialMessages ?? []);
|
|
116
540
|
const [isLoading, setIsLoading] = useState2(false);
|
|
117
541
|
const [error, setError] = useState2(null);
|
|
118
|
-
const
|
|
542
|
+
const [runId, setRunId] = useState2(null);
|
|
543
|
+
const runRef = useRef2(null);
|
|
544
|
+
const resumedRunIdRef = useRef2(null);
|
|
545
|
+
const manuallyStoppedRef = useRef2(false);
|
|
119
546
|
const messagesRef = useRef2(messages);
|
|
120
547
|
messagesRef.current = messages;
|
|
121
|
-
const
|
|
122
|
-
|
|
548
|
+
const updateMessages = useCallback2(
|
|
549
|
+
(next) => {
|
|
550
|
+
setMessages((prev) => {
|
|
551
|
+
const resolved = typeof next === "function" ? next(prev) : next;
|
|
552
|
+
messagesRef.current = resolved;
|
|
553
|
+
return resolved;
|
|
554
|
+
});
|
|
555
|
+
},
|
|
556
|
+
[]
|
|
557
|
+
);
|
|
558
|
+
const transportOptionsRef = useRef2(options.transportOptions);
|
|
559
|
+
transportOptionsRef.current = options.transportOptions;
|
|
560
|
+
const pendingTextRef = useRef2("");
|
|
561
|
+
const textFlushTimerRef = useRef2(null);
|
|
562
|
+
const flushPendingText = useCallback2(() => {
|
|
563
|
+
if (textFlushTimerRef.current !== null) {
|
|
564
|
+
clearTimeout(textFlushTimerRef.current);
|
|
565
|
+
textFlushTimerRef.current = null;
|
|
566
|
+
}
|
|
567
|
+
const delta = pendingTextRef.current;
|
|
568
|
+
if (!delta) return;
|
|
569
|
+
pendingTextRef.current = "";
|
|
570
|
+
updateMessages((prev) => {
|
|
123
571
|
const last = prev[prev.length - 1];
|
|
124
572
|
if (!last || last.role !== "assistant") return prev;
|
|
125
573
|
const parts = [...last.parts];
|
|
@@ -133,122 +581,160 @@ function useStreamChat(options) {
|
|
|
133
581
|
} else {
|
|
134
582
|
parts.push({ type: "text", text: delta });
|
|
135
583
|
}
|
|
136
|
-
|
|
137
|
-
return [...prev.slice(0, -1), updated];
|
|
584
|
+
return [...prev.slice(0, -1), { ...last, parts }];
|
|
138
585
|
});
|
|
139
|
-
}, []);
|
|
586
|
+
}, [updateMessages]);
|
|
587
|
+
const appendText = useCallback2((delta) => {
|
|
588
|
+
if (textBatchMs <= 0) {
|
|
589
|
+
pendingTextRef.current += delta;
|
|
590
|
+
flushPendingText();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
pendingTextRef.current += delta;
|
|
594
|
+
if (textFlushTimerRef.current !== null) return;
|
|
595
|
+
textFlushTimerRef.current = setTimeout(() => {
|
|
596
|
+
flushPendingText();
|
|
597
|
+
}, textBatchMs);
|
|
598
|
+
}, [flushPendingText, textBatchMs]);
|
|
140
599
|
const appendPart = useCallback2((part) => {
|
|
141
|
-
|
|
600
|
+
flushPendingText();
|
|
601
|
+
updateMessages((prev) => {
|
|
142
602
|
const last = prev[prev.length - 1];
|
|
143
603
|
if (!last || last.role !== "assistant") return prev;
|
|
144
|
-
|
|
145
|
-
...
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
604
|
+
return [
|
|
605
|
+
...prev.slice(0, -1),
|
|
606
|
+
{
|
|
607
|
+
...last,
|
|
608
|
+
parts: [...last.parts, part]
|
|
609
|
+
}
|
|
610
|
+
];
|
|
149
611
|
});
|
|
150
|
-
}, []);
|
|
612
|
+
}, [flushPendingText, updateMessages]);
|
|
613
|
+
const transportRef = useRef2(null);
|
|
614
|
+
if (!transportRef.current) {
|
|
615
|
+
transportRef.current = resolveTransport(options);
|
|
616
|
+
}
|
|
617
|
+
const transport = transportRef.current;
|
|
618
|
+
const eventHandler = onEvent ?? defaultOnEvent;
|
|
619
|
+
const eventHandlerRef = useRef2(eventHandler);
|
|
620
|
+
eventHandlerRef.current = eventHandler;
|
|
621
|
+
const onErrorRef = useRef2(onError);
|
|
622
|
+
onErrorRef.current = onError;
|
|
623
|
+
const onFinishRef = useRef2(onFinish);
|
|
624
|
+
onFinishRef.current = onFinish;
|
|
625
|
+
const onMessageRef = useRef2(onMessage);
|
|
626
|
+
onMessageRef.current = onMessage;
|
|
627
|
+
const transportContext = useMemo(
|
|
628
|
+
() => createChatTransportContext({
|
|
629
|
+
appendPart,
|
|
630
|
+
appendText,
|
|
631
|
+
eventHandler: (event, helpers) => eventHandlerRef.current(event, helpers),
|
|
632
|
+
getMessages: () => {
|
|
633
|
+
flushPendingText();
|
|
634
|
+
return messagesRef.current;
|
|
635
|
+
},
|
|
636
|
+
onError: (...args) => onErrorRef.current?.(...args),
|
|
637
|
+
onFinish: (...args) => onFinishRef.current?.(...args),
|
|
638
|
+
onMessage: (...args) => onMessageRef.current?.(...args),
|
|
639
|
+
setError,
|
|
640
|
+
setIsLoading,
|
|
641
|
+
setMessages: updateMessages,
|
|
642
|
+
setRunId: (next) => {
|
|
643
|
+
setRunId(next);
|
|
644
|
+
transportOptionsRef.current?.backgroundSSE?.onRunIdChange?.(next);
|
|
645
|
+
transportOptionsRef.current?.websocket?.onRunIdChange?.(next);
|
|
646
|
+
}
|
|
647
|
+
}),
|
|
648
|
+
[appendPart, appendText, flushPendingText, updateMessages]
|
|
649
|
+
);
|
|
151
650
|
const stop = useCallback2(() => {
|
|
152
|
-
|
|
153
|
-
|
|
651
|
+
const activeRun = runRef.current;
|
|
652
|
+
if (!activeRun?.stop) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
manuallyStoppedRef.current = true;
|
|
656
|
+
void activeRun.stop();
|
|
657
|
+
runRef.current = null;
|
|
154
658
|
setIsLoading(false);
|
|
155
659
|
}, []);
|
|
156
660
|
const sendMessage = useCallback2(
|
|
157
661
|
(text) => {
|
|
158
|
-
if (
|
|
662
|
+
if (runRef.current || isLoading) {
|
|
159
663
|
return;
|
|
160
664
|
}
|
|
161
665
|
const userMessage = createMessage("user", [
|
|
162
666
|
{ type: "text", text }
|
|
163
667
|
]);
|
|
164
668
|
const assistantMessage = createMessage("assistant", []);
|
|
165
|
-
|
|
669
|
+
updateMessages((prev) => {
|
|
166
670
|
const next = [...prev, userMessage, assistantMessage];
|
|
167
|
-
messagesRef.current = next;
|
|
168
671
|
return next;
|
|
169
672
|
});
|
|
170
673
|
onMessage?.(userMessage);
|
|
171
674
|
setError(null);
|
|
172
675
|
setIsLoading(true);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
};
|
|
181
|
-
(async () => {
|
|
182
|
-
try {
|
|
183
|
-
const response = await fetch(api, {
|
|
184
|
-
method: "POST",
|
|
185
|
-
headers: {
|
|
186
|
-
"Content-Type": "application/json",
|
|
187
|
-
...headers
|
|
188
|
-
},
|
|
189
|
-
body: JSON.stringify({ message: text, ...body }),
|
|
190
|
-
signal: controller.signal
|
|
191
|
-
});
|
|
192
|
-
if (!response.ok) {
|
|
193
|
-
throw new Error(
|
|
194
|
-
`SSE request failed: ${response.status} ${response.statusText}`
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
if (!response.body) {
|
|
198
|
-
throw new Error(
|
|
199
|
-
"Response body is null \u2014 SSE streaming not supported"
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
for await (const event of parseSSEStream(
|
|
203
|
-
response.body,
|
|
204
|
-
controller.signal
|
|
205
|
-
)) {
|
|
206
|
-
eventHandler(event, helpers);
|
|
207
|
-
}
|
|
208
|
-
const finalMessages = messagesRef.current;
|
|
209
|
-
const lastMessage = finalMessages[finalMessages.length - 1];
|
|
210
|
-
if (lastMessage?.role === "assistant") {
|
|
211
|
-
onMessage?.(lastMessage);
|
|
212
|
-
}
|
|
213
|
-
onFinish?.(finalMessages);
|
|
214
|
-
} catch (err) {
|
|
215
|
-
if (err instanceof DOMException && err.name === "AbortError") {
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const error2 = err instanceof Error ? err : new Error(String(err));
|
|
219
|
-
setError(error2);
|
|
220
|
-
onError?.(error2);
|
|
221
|
-
} finally {
|
|
222
|
-
abortRef.current = null;
|
|
223
|
-
setIsLoading(false);
|
|
224
|
-
}
|
|
225
|
-
})();
|
|
676
|
+
resumedRunIdRef.current = null;
|
|
677
|
+
manuallyStoppedRef.current = false;
|
|
678
|
+
const run = transport.start({ context: transportContext, message: text });
|
|
679
|
+
runRef.current = run ?? null;
|
|
680
|
+
if (run?.runId) {
|
|
681
|
+
setRunId(run.runId);
|
|
682
|
+
}
|
|
226
683
|
},
|
|
227
|
-
[
|
|
228
|
-
api,
|
|
229
|
-
headers,
|
|
230
|
-
body,
|
|
231
|
-
onEvent,
|
|
232
|
-
onMessage,
|
|
233
|
-
onError,
|
|
234
|
-
onFinish,
|
|
235
|
-
appendText,
|
|
236
|
-
appendPart
|
|
237
|
-
]
|
|
684
|
+
[isLoading, onMessage, transport, transportContext, updateMessages]
|
|
238
685
|
);
|
|
686
|
+
const candidateRunId = options.transportOptions?.backgroundSSE?.runId ?? options.transportOptions?.backgroundSSE?.getResumeKey?.() ?? options.transportOptions?.websocket?.runId ?? options.transportOptions?.websocket?.getResumeKey?.() ?? null;
|
|
687
|
+
useEffect2(() => {
|
|
688
|
+
if (runRef.current) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (!candidateRunId) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (manuallyStoppedRef.current) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (resumedRunIdRef.current === candidateRunId) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (!transport.resume) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
resumedRunIdRef.current = candidateRunId;
|
|
704
|
+
setError(null);
|
|
705
|
+
setIsLoading(true);
|
|
706
|
+
const run = transport.resume({
|
|
707
|
+
context: transportContext,
|
|
708
|
+
runId: candidateRunId
|
|
709
|
+
});
|
|
710
|
+
runRef.current = run ?? null;
|
|
711
|
+
setRunId(candidateRunId);
|
|
712
|
+
}, [candidateRunId, transport, transportContext]);
|
|
239
713
|
useEffect2(() => {
|
|
240
714
|
return () => {
|
|
241
|
-
|
|
242
|
-
|
|
715
|
+
if (textFlushTimerRef.current !== null) {
|
|
716
|
+
clearTimeout(textFlushTimerRef.current);
|
|
717
|
+
textFlushTimerRef.current = null;
|
|
718
|
+
}
|
|
719
|
+
void runRef.current?.close?.();
|
|
720
|
+
runRef.current = null;
|
|
243
721
|
};
|
|
244
722
|
}, []);
|
|
723
|
+
const prevIsLoadingRef = useRef2(isLoading);
|
|
724
|
+
useEffect2(() => {
|
|
725
|
+
if (prevIsLoadingRef.current && !isLoading) {
|
|
726
|
+
runRef.current = null;
|
|
727
|
+
}
|
|
728
|
+
prevIsLoadingRef.current = isLoading;
|
|
729
|
+
}, [isLoading]);
|
|
245
730
|
return {
|
|
246
|
-
messages,
|
|
247
|
-
isLoading,
|
|
248
731
|
error,
|
|
732
|
+
isLoading,
|
|
733
|
+
messages,
|
|
734
|
+
runId,
|
|
249
735
|
sendMessage,
|
|
250
|
-
|
|
251
|
-
|
|
736
|
+
setMessages: updateMessages,
|
|
737
|
+
stop
|
|
252
738
|
};
|
|
253
739
|
}
|
|
254
740
|
export {
|