@haikit/client 0.19.0 → 0.20.1
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 +11 -0
- package/package.json +1 -1
- package/src/index.d.ts +11 -0
- package/src/index.js +119 -4
package/README.md
CHANGED
|
@@ -123,6 +123,17 @@ mountChat({
|
|
|
123
123
|
});
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
If something on the network holds the stream back, as HTTPS-inspecting
|
|
127
|
+
corporate proxies (Netskope, Zscaler) do, the chat polls instead. The server
|
|
128
|
+
greets every stream with a comment the moment it opens. A stream that brings
|
|
129
|
+
no bytes at all within `eventsFallbackMs` (default 8 s, a `createChat`
|
|
130
|
+
option) is dropped for short polls, whose responses end and so get through.
|
|
131
|
+
Notices, signals and wake turns all arrive by polling, a little later than
|
|
132
|
+
they would stream. Each poll asks the server to hold for `pollHoldMs`
|
|
133
|
+
(default 15 s, the server's maximum) when nothing is new; set it lower
|
|
134
|
+
behind a proxy that drops requests sooner. The browser remembers it had to poll, in `localStorage`
|
|
135
|
+
under `hai:events-poll`, for a day, so its next page polls from the start.
|
|
136
|
+
|
|
126
137
|
A `wake` notice's turn comes down the same stream: its reply appears under the
|
|
127
138
|
notice like any other. While it runs, `send` and `interact` wait for it to
|
|
128
139
|
finish rather than being refused, just as they wait behind this chat's own
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -245,4 +245,15 @@ export function createChat(options: {
|
|
|
245
245
|
registry: Registry;
|
|
246
246
|
notices?: NoticeRegistry;
|
|
247
247
|
signals?: SignalRegistry;
|
|
248
|
+
/**
|
|
249
|
+
* How long the events stream may stay silent as it opens before the client
|
|
250
|
+
* decides something on the way is holding it back, and polls instead.
|
|
251
|
+
* Default 8000 ms.
|
|
252
|
+
*/
|
|
253
|
+
eventsFallbackMs?: number;
|
|
254
|
+
/**
|
|
255
|
+
* When polling, how long each poll asks the server to hold if nothing new
|
|
256
|
+
* has come (the server allows at most 15 s). Default 15000 ms.
|
|
257
|
+
*/
|
|
258
|
+
pollHoldMs?: number;
|
|
248
259
|
}): Chat;
|
package/src/index.js
CHANGED
|
@@ -12,7 +12,14 @@
|
|
|
12
12
|
/**
|
|
13
13
|
* @param {{endpoint?: string, registry: Record<string, {mount: Function}>, notices?: Record<string, {mount: Function}>}} options
|
|
14
14
|
*/
|
|
15
|
-
export function createChat({
|
|
15
|
+
export function createChat({
|
|
16
|
+
endpoint = "/hai",
|
|
17
|
+
registry,
|
|
18
|
+
notices = {},
|
|
19
|
+
signals = {},
|
|
20
|
+
eventsFallbackMs = EVENTS_FALLBACK_MS,
|
|
21
|
+
pollHoldMs = POLL_HOLD_MS,
|
|
22
|
+
}) {
|
|
16
23
|
const listeners = new Set();
|
|
17
24
|
const state = {
|
|
18
25
|
conversationId: null,
|
|
@@ -111,6 +118,15 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
|
|
|
111
118
|
* so `state.notices` alone would miss them.
|
|
112
119
|
*/
|
|
113
120
|
let resumeAfter = 0;
|
|
121
|
+
/**
|
|
122
|
+
* Whether the events stream is read by polling: a browser behind something
|
|
123
|
+
* that holds a stream back until it ends switches to it, and remembers that
|
|
124
|
+
* for a day, so its next page skips the wait.
|
|
125
|
+
*/
|
|
126
|
+
let polling = rememberedPolling();
|
|
127
|
+
/** The wake turn showing, and the last one seen to end, by number: a poll says the latter. */
|
|
128
|
+
let turnShowing = 0;
|
|
129
|
+
let turnSeen = 0;
|
|
114
130
|
|
|
115
131
|
// ── transport: SSE over POST (EventSource cannot POST) ──────────────
|
|
116
132
|
function enqueue(path, body) {
|
|
@@ -443,17 +459,40 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
|
|
|
443
459
|
|
|
444
460
|
async function follow(conversationId, gen, signal) {
|
|
445
461
|
let wait = 1_000;
|
|
462
|
+
// a poll's first answer comes at once, with the latest of each signal
|
|
463
|
+
let firstPoll = true;
|
|
446
464
|
while (gen === generation && !signal.aborted) {
|
|
447
465
|
// A connection that delivered a notice, or stayed up a while, was
|
|
448
466
|
// healthy, and the next drop starts the backoff over. One that drops
|
|
449
467
|
// straight after it opens was not, however often the server says 200.
|
|
450
468
|
const opened = Date.now();
|
|
451
469
|
let delivered = false;
|
|
470
|
+
// This attempt's own abort, so silence can end it without ending the chat.
|
|
471
|
+
const attempt = new AbortController();
|
|
472
|
+
const stop = () => attempt.abort();
|
|
473
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
474
|
+
// The server sends a comment the moment a stream opens. Nothing at all
|
|
475
|
+
// within eventsFallbackMs means something on the way, such as a proxy
|
|
476
|
+
// that inspects traffic, is holding the stream back until it ends,
|
|
477
|
+
// which it never does: poll instead, with responses that end.
|
|
478
|
+
let silent = false;
|
|
479
|
+
const silence = polling
|
|
480
|
+
? null
|
|
481
|
+
: setTimeout(() => {
|
|
482
|
+
silent = true;
|
|
483
|
+
attempt.abort();
|
|
484
|
+
}, eventsFallbackMs);
|
|
485
|
+
|
|
486
|
+
let ended = false;
|
|
452
487
|
try {
|
|
453
488
|
const last = Math.max(resumeAfter, state.notices.at(-1)?.seq ?? 0);
|
|
454
|
-
|
|
489
|
+
// with a fingerprint of the signals it has, so a poll knows if their latest are news
|
|
490
|
+
const query = polling
|
|
491
|
+
? `&poll=${firstPoll ? "now" : "1"}&wait=${pollHoldMs}&turn=${turnSeen}&signals=${signalsDigest(Object.entries(state.signals))}`
|
|
492
|
+
: "";
|
|
493
|
+
const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}${query}`, {
|
|
455
494
|
headers: last ? { "last-event-id": String(last) } : {},
|
|
456
|
-
signal,
|
|
495
|
+
signal: attempt.signal,
|
|
457
496
|
});
|
|
458
497
|
// the server has no such conversation, or sends no notices: stop
|
|
459
498
|
if (res.status === 404) return;
|
|
@@ -462,7 +501,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
|
|
|
462
501
|
let buffer = "";
|
|
463
502
|
for (;;) {
|
|
464
503
|
const { value, done } = await reader.read();
|
|
465
|
-
|
|
504
|
+
clearTimeout(silence);
|
|
505
|
+
if (done) {
|
|
506
|
+
ended = true;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
466
509
|
buffer += value;
|
|
467
510
|
let i;
|
|
468
511
|
while ((i = buffer.indexOf("\n\n")) >= 0) {
|
|
@@ -489,6 +532,22 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
|
|
|
489
532
|
}
|
|
490
533
|
} catch {
|
|
491
534
|
if (signal.aborted) return finishRemote();
|
|
535
|
+
} finally {
|
|
536
|
+
clearTimeout(silence);
|
|
537
|
+
signal.removeEventListener("abort", stop);
|
|
538
|
+
}
|
|
539
|
+
if (silent) {
|
|
540
|
+
polling = true;
|
|
541
|
+
rememberPolling();
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
// A poll that ended as polls do: the next one at once, a moment apart.
|
|
545
|
+
// A wake turn it was part way through goes on in the next, which is sent
|
|
546
|
+
// what the turn has shown so far, or that none is running.
|
|
547
|
+
if (polling && ended && gen === generation && !signal.aborted) {
|
|
548
|
+
firstPoll = false;
|
|
549
|
+
await pause(Math.max(0, POLL_GAP_MS - (Date.now() - opened)), signal);
|
|
550
|
+
continue;
|
|
492
551
|
}
|
|
493
552
|
// A turn cut off with its connection runs on, but nothing will say when
|
|
494
553
|
// it ends: stop waiting for it, and give the next request longer instead.
|
|
@@ -518,10 +577,13 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
|
|
|
518
577
|
*/
|
|
519
578
|
function followTurn(event) {
|
|
520
579
|
if (event.type === "status" && event.status === "streaming") {
|
|
580
|
+
// the turn's number, so a poll can say which turn it last saw end
|
|
581
|
+
if (typeof event.wake === "number") turnShowing = event.wake;
|
|
521
582
|
if (!remote) remote = new Promise((resolve) => (endRemote = resolve));
|
|
522
583
|
// surfaces it shows are stamped after this, as a request's are after it is sent
|
|
523
584
|
sentAt = Date.now();
|
|
524
585
|
} else if (event.type === "released") {
|
|
586
|
+
if (turnShowing > turnSeen) turnSeen = turnShowing;
|
|
525
587
|
finishRemote();
|
|
526
588
|
}
|
|
527
589
|
}
|
|
@@ -747,6 +809,9 @@ export function createChat({ endpoint = "/hai", registry, notices = {}, signals
|
|
|
747
809
|
stopListening();
|
|
748
810
|
cutOff = false;
|
|
749
811
|
resumeAfter = 0;
|
|
812
|
+
// turns are numbered per conversation
|
|
813
|
+
turnShowing = 0;
|
|
814
|
+
turnSeen = 0;
|
|
750
815
|
clearTimeout(timer);
|
|
751
816
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
752
817
|
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
@@ -815,6 +880,56 @@ const CUT_OFF_MS = 120_000;
|
|
|
815
880
|
/** An events connection that stays up this long was healthy, whatever it delivered. */
|
|
816
881
|
const HEALTHY_MS = 30_000;
|
|
817
882
|
|
|
883
|
+
/** How long the events stream may stay silent as it opens before the client polls instead. */
|
|
884
|
+
const EVENTS_FALLBACK_MS = 8_000;
|
|
885
|
+
/** The least time from one poll's start to the next's. */
|
|
886
|
+
const POLL_GAP_MS = 250;
|
|
887
|
+
/** How long a poll asks the server to hold when nothing new has come: the server allows at most 15 s. */
|
|
888
|
+
const POLL_HOLD_MS = 15_000;
|
|
889
|
+
/** Where a browser remembers it had to poll, and for how long. */
|
|
890
|
+
const POLL_KEY = "hai:events-poll";
|
|
891
|
+
const POLL_REMEMBER_MS = 24 * 60 * 60_000;
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* A short fingerprint of signal values, by name, as a poll reports what this
|
|
895
|
+
* browser has. The server computes the same (packages/server/src/routes.ts);
|
|
896
|
+
* change both together.
|
|
897
|
+
*/
|
|
898
|
+
function signalsDigest(entries) {
|
|
899
|
+
const text = JSON.stringify([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
900
|
+
let hash = 0x811c9dc5;
|
|
901
|
+
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
|
902
|
+
return (hash >>> 0).toString(16);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function rememberedPolling() {
|
|
906
|
+
try {
|
|
907
|
+
const at = Number(localStorage.getItem(POLL_KEY));
|
|
908
|
+
return Number.isFinite(at) && at > 0 && Date.now() - at < POLL_REMEMBER_MS;
|
|
909
|
+
} catch {
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function rememberPolling() {
|
|
915
|
+
try {
|
|
916
|
+
localStorage.setItem(POLL_KEY, String(Date.now()));
|
|
917
|
+
} catch {}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Wait `ms`, or until `signal` aborts. */
|
|
921
|
+
function pause(ms, signal) {
|
|
922
|
+
return new Promise((resolve) => {
|
|
923
|
+
const done = () => {
|
|
924
|
+
clearTimeout(timer);
|
|
925
|
+
signal.removeEventListener("abort", done);
|
|
926
|
+
resolve();
|
|
927
|
+
};
|
|
928
|
+
const timer = setTimeout(done, ms);
|
|
929
|
+
signal.addEventListener("abort", done);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
818
933
|
/**
|
|
819
934
|
* Run `fn`, reporting what it throws without letting it stop the caller: the
|
|
820
935
|
* error surfaces as an uncaught one would, after the caller has finished.
|