@haikit/client 0.18.0 → 0.20.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 +34 -0
- package/package.json +1 -1
- package/src/app.d.ts +3 -1
- package/src/app.js +2 -1
- package/src/index.d.ts +28 -1
- package/src/index.js +135 -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
|
|
@@ -135,6 +146,29 @@ name the registry doesn't list renders an error card. Like a surface, `mount`
|
|
|
135
146
|
may return `{ unmount }`. With `createChat`, mount one yourself with
|
|
136
147
|
`chat.mountNotice(seq, element)`.
|
|
137
148
|
|
|
149
|
+
## Signals
|
|
150
|
+
|
|
151
|
+
A signal (`hai.signal`) is a broadcast to every open browser, such as how many
|
|
152
|
+
people are online. It comes down the same events stream, and goes to a
|
|
153
|
+
handler, never to the transcript:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
mountChat({
|
|
157
|
+
root, registry,
|
|
158
|
+
signals: {
|
|
159
|
+
online(payload, { version }) {
|
|
160
|
+
badge.textContent = `${payload.count} reading now`;
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Each signal's latest payload is also in `state.signals`, by name, and
|
|
167
|
+
subscribers hear a `signal` event. A stream that opens is sent each signal's
|
|
168
|
+
latest value first, so the handler runs as soon as the events stream is up.
|
|
169
|
+
Signals carry no id: a reconnect is sent the latest values again rather than
|
|
170
|
+
the ones it missed.
|
|
171
|
+
|
|
138
172
|
## Out-of-date conversations
|
|
139
173
|
|
|
140
174
|
The server closes a conversation once any surface passes its freshness window.
|
package/package.json
CHANGED
package/src/app.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { Chat, NoticeRegistry, Registry } from "./index.js";
|
|
1
|
+
import type { Chat, NoticeRegistry, Registry, SignalRegistry } from "./index.js";
|
|
2
2
|
|
|
3
3
|
export interface MountChatOptions {
|
|
4
4
|
root: HTMLElement;
|
|
5
5
|
registry: Registry;
|
|
6
6
|
/** Components for the notices the server sends, by notice name. */
|
|
7
7
|
notices?: NoticeRegistry;
|
|
8
|
+
/** Handlers for the signals the server sends, by signal name. */
|
|
9
|
+
signals?: SignalRegistry;
|
|
8
10
|
/** Base path the server's two routes are mounted at. Default `/hai`. */
|
|
9
11
|
endpoint?: string;
|
|
10
12
|
title?: string;
|
package/src/app.js
CHANGED
|
@@ -101,6 +101,7 @@ export function mountChat({
|
|
|
101
101
|
root,
|
|
102
102
|
registry,
|
|
103
103
|
notices,
|
|
104
|
+
signals,
|
|
104
105
|
endpoint = "/hai",
|
|
105
106
|
title = "HaiKIT",
|
|
106
107
|
subtitle = "",
|
|
@@ -110,7 +111,7 @@ export function mountChat({
|
|
|
110
111
|
emptyText = "",
|
|
111
112
|
theme,
|
|
112
113
|
}) {
|
|
113
|
-
const chat = createChat({ endpoint, registry, notices });
|
|
114
|
+
const chat = createChat({ endpoint, registry, notices, signals });
|
|
114
115
|
const id = `hai-${++shells}`;
|
|
115
116
|
|
|
116
117
|
// Unset, the page follows the system. A pin goes on the root, where hai.css
|
package/src/index.d.ts
CHANGED
|
@@ -128,6 +128,15 @@ export interface NoticeDef<P = any> {
|
|
|
128
128
|
mount(element: HTMLElement, payload: P, ctx: NoticeMountCtx): NoticeInstance | null | void;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* What the browser does with a signal: a handler, called with its payload each
|
|
133
|
+
* time one arrives. Signals are never drawn in the transcript.
|
|
134
|
+
*/
|
|
135
|
+
export type SignalHandler<P = any> = (payload: P, ctx: { version: number }) => void;
|
|
136
|
+
|
|
137
|
+
/** Handlers for the signals the server sends, by signal name. */
|
|
138
|
+
export type SignalRegistry = Record<string, SignalHandler<any>>;
|
|
139
|
+
|
|
131
140
|
/** The notices allowlist, by notice name. Separate from `Registry`, so names never clash. */
|
|
132
141
|
export type NoticeRegistry = Record<string, NoticeDef<any>>;
|
|
133
142
|
|
|
@@ -171,6 +180,8 @@ export interface ChatState {
|
|
|
171
180
|
expired: string | null;
|
|
172
181
|
/** Notices the server has sent this conversation, in order. */
|
|
173
182
|
notices: NoticeRecord[];
|
|
183
|
+
/** Each signal's latest payload, by name, since the events stream opened. */
|
|
184
|
+
signals: Record<string, unknown>;
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
export interface Chat {
|
|
@@ -229,4 +240,20 @@ export interface Chat {
|
|
|
229
240
|
* `GET {endpoint}/events` open for the conversation, resuming after the last
|
|
230
241
|
* notice it saw if the connection drops, until `reset()` or `close()`.
|
|
231
242
|
*/
|
|
232
|
-
export function createChat(options: {
|
|
243
|
+
export function createChat(options: {
|
|
244
|
+
endpoint?: string;
|
|
245
|
+
registry: Registry;
|
|
246
|
+
notices?: NoticeRegistry;
|
|
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;
|
|
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,
|
|
@@ -33,6 +40,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
33
40
|
expired: null,
|
|
34
41
|
/** Notices the server has sent this conversation, in the order they were sent. */
|
|
35
42
|
notices: [],
|
|
43
|
+
/**
|
|
44
|
+
* Each signal's latest payload, by name, since the events stream opened.
|
|
45
|
+
* No prototype, so a signal can be named anything, `__proto__` included.
|
|
46
|
+
*/
|
|
47
|
+
signals: Object.create(null),
|
|
36
48
|
};
|
|
37
49
|
|
|
38
50
|
const notify = (event) => listeners.forEach((fn) => fn(state, event));
|
|
@@ -106,6 +118,15 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
106
118
|
* so `state.notices` alone would miss them.
|
|
107
119
|
*/
|
|
108
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;
|
|
109
130
|
|
|
110
131
|
// ── transport: SSE over POST (EventSource cannot POST) ──────────────
|
|
111
132
|
function enqueue(path, body) {
|
|
@@ -251,6 +272,16 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
251
272
|
break;
|
|
252
273
|
}
|
|
253
274
|
|
|
275
|
+
// A broadcast to every open browser. Never stored, so there is nothing
|
|
276
|
+
// to resume: its latest value is kept, and its handler hears it.
|
|
277
|
+
case "signal": {
|
|
278
|
+
state.signals[event.name] = event.payload;
|
|
279
|
+
// the app's own handlers only, never one an object inherits, such as `toString`
|
|
280
|
+
const handler = Object.hasOwn(signals, event.name) ? signals[event.name] : undefined;
|
|
281
|
+
if (typeof handler === "function") attempt(() => handler(event.payload, { version: event.version }));
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
|
|
254
285
|
// A stream that rejoins a wake turn is sent what the turn has shown so
|
|
255
286
|
// far: a block this client already has is brought up to date, not added
|
|
256
287
|
// again.
|
|
@@ -428,17 +459,40 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
428
459
|
|
|
429
460
|
async function follow(conversationId, gen, signal) {
|
|
430
461
|
let wait = 1_000;
|
|
462
|
+
// a poll's first answer comes at once, with the latest of each signal
|
|
463
|
+
let firstPoll = true;
|
|
431
464
|
while (gen === generation && !signal.aborted) {
|
|
432
465
|
// A connection that delivered a notice, or stayed up a while, was
|
|
433
466
|
// healthy, and the next drop starts the backoff over. One that drops
|
|
434
467
|
// straight after it opens was not, however often the server says 200.
|
|
435
468
|
const opened = Date.now();
|
|
436
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;
|
|
437
487
|
try {
|
|
438
488
|
const last = Math.max(resumeAfter, state.notices.at(-1)?.seq ?? 0);
|
|
439
|
-
|
|
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}`, {
|
|
440
494
|
headers: last ? { "last-event-id": String(last) } : {},
|
|
441
|
-
signal,
|
|
495
|
+
signal: attempt.signal,
|
|
442
496
|
});
|
|
443
497
|
// the server has no such conversation, or sends no notices: stop
|
|
444
498
|
if (res.status === 404) return;
|
|
@@ -447,7 +501,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
447
501
|
let buffer = "";
|
|
448
502
|
for (;;) {
|
|
449
503
|
const { value, done } = await reader.read();
|
|
450
|
-
|
|
504
|
+
clearTimeout(silence);
|
|
505
|
+
if (done) {
|
|
506
|
+
ended = true;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
451
509
|
buffer += value;
|
|
452
510
|
let i;
|
|
453
511
|
while ((i = buffer.indexOf("\n\n")) >= 0) {
|
|
@@ -474,6 +532,22 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
474
532
|
}
|
|
475
533
|
} catch {
|
|
476
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;
|
|
477
551
|
}
|
|
478
552
|
// A turn cut off with its connection runs on, but nothing will say when
|
|
479
553
|
// it ends: stop waiting for it, and give the next request longer instead.
|
|
@@ -503,10 +577,13 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
503
577
|
*/
|
|
504
578
|
function followTurn(event) {
|
|
505
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;
|
|
506
582
|
if (!remote) remote = new Promise((resolve) => (endRemote = resolve));
|
|
507
583
|
// surfaces it shows are stamped after this, as a request's are after it is sent
|
|
508
584
|
sentAt = Date.now();
|
|
509
585
|
} else if (event.type === "released") {
|
|
586
|
+
if (turnShowing > turnSeen) turnSeen = turnShowing;
|
|
510
587
|
finishRemote();
|
|
511
588
|
}
|
|
512
589
|
}
|
|
@@ -732,6 +809,9 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
732
809
|
stopListening();
|
|
733
810
|
cutOff = false;
|
|
734
811
|
resumeAfter = 0;
|
|
812
|
+
// turns are numbered per conversation
|
|
813
|
+
turnShowing = 0;
|
|
814
|
+
turnSeen = 0;
|
|
735
815
|
clearTimeout(timer);
|
|
736
816
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
737
817
|
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
@@ -743,6 +823,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
743
823
|
expiresAt: null,
|
|
744
824
|
expired: null,
|
|
745
825
|
notices: [],
|
|
826
|
+
signals: Object.create(null),
|
|
746
827
|
});
|
|
747
828
|
state.surfaces.clear();
|
|
748
829
|
replacedBy.clear();
|
|
@@ -799,6 +880,56 @@ const CUT_OFF_MS = 120_000;
|
|
|
799
880
|
/** An events connection that stays up this long was healthy, whatever it delivered. */
|
|
800
881
|
const HEALTHY_MS = 30_000;
|
|
801
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
|
+
|
|
802
933
|
/**
|
|
803
934
|
* Run `fn`, reporting what it throws without letting it stop the caller: the
|
|
804
935
|
* error surfaces as an uncaught one would, after the caller has finished.
|