@haikit/client 0.17.0 → 0.19.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 +26 -0
- package/package.json +1 -1
- package/src/app.d.ts +3 -1
- package/src/app.js +2 -1
- package/src/index.d.ts +17 -1
- package/src/index.js +89 -16
- package/src/transcript.js +9 -8
package/README.md
CHANGED
|
@@ -89,6 +89,9 @@ component holds, such as its scroll, sort or a row the user picked. Without
|
|
|
89
89
|
it, the component is mounted again in place. Either way, `ctx.send` names the
|
|
90
90
|
surface's current handle, so a click after a revision reaches the new one.
|
|
91
91
|
Each surface keeps its own deadline, and a replaced surface's stops counting.
|
|
92
|
+
A revision app code makes from outside a turn (`hai.update`) arrives the same
|
|
93
|
+
way, on the events stream, and its deadline counts from when the server
|
|
94
|
+
stored it.
|
|
92
95
|
|
|
93
96
|
`chat.close()` is for good: anything open is aborted, every surface is
|
|
94
97
|
unmounted, and subscribers hear `{ type: "closed" }`. `mountChat` then removes
|
|
@@ -132,6 +135,29 @@ name the registry doesn't list renders an error card. Like a surface, `mount`
|
|
|
132
135
|
may return `{ unmount }`. With `createChat`, mount one yourself with
|
|
133
136
|
`chat.mountNotice(seq, element)`.
|
|
134
137
|
|
|
138
|
+
## Signals
|
|
139
|
+
|
|
140
|
+
A signal (`hai.signal`) is a broadcast to every open browser, such as how many
|
|
141
|
+
people are online. It comes down the same events stream, and goes to a
|
|
142
|
+
handler, never to the transcript:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
mountChat({
|
|
146
|
+
root, registry,
|
|
147
|
+
signals: {
|
|
148
|
+
online(payload, { version }) {
|
|
149
|
+
badge.textContent = `${payload.count} reading now`;
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Each signal's latest payload is also in `state.signals`, by name, and
|
|
156
|
+
subscribers hear a `signal` event. A stream that opens is sent each signal's
|
|
157
|
+
latest value first, so the handler runs as soon as the events stream is up.
|
|
158
|
+
Signals carry no id: a reconnect is sent the latest values again rather than
|
|
159
|
+
the ones it missed.
|
|
160
|
+
|
|
135
161
|
## Out-of-date conversations
|
|
136
162
|
|
|
137
163
|
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,9 @@ 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
|
+
}): Chat;
|
package/src/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
/**
|
|
13
13
|
* @param {{endpoint?: string, registry: Record<string, {mount: Function}>, notices?: Record<string, {mount: Function}>}} options
|
|
14
14
|
*/
|
|
15
|
-
export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
15
|
+
export function createChat({ endpoint = "/hai", registry, notices = {}, signals = {} }) {
|
|
16
16
|
const listeners = new Set();
|
|
17
17
|
const state = {
|
|
18
18
|
conversationId: null,
|
|
@@ -33,9 +33,19 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
33
33
|
expired: null,
|
|
34
34
|
/** Notices the server has sent this conversation, in the order they were sent. */
|
|
35
35
|
notices: [],
|
|
36
|
+
/**
|
|
37
|
+
* Each signal's latest payload, by name, since the events stream opened.
|
|
38
|
+
* No prototype, so a signal can be named anything, `__proto__` included.
|
|
39
|
+
*/
|
|
40
|
+
signals: Object.create(null),
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
const notify = (event) => listeners.forEach((fn) => fn(state, event));
|
|
44
|
+
/**
|
|
45
|
+
* handle -> the handle that replaced it, for every revision this browser has
|
|
46
|
+
* heard of, including ones it only heard named; followed by `current`.
|
|
47
|
+
*/
|
|
48
|
+
const replacedBy = new Map();
|
|
39
49
|
|
|
40
50
|
/**
|
|
41
51
|
* Requests open or waiting to go out — which is not the same as
|
|
@@ -95,6 +105,12 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
95
105
|
* a buffering proxy holds the stream back.
|
|
96
106
|
*/
|
|
97
107
|
let sentAt = 0;
|
|
108
|
+
/**
|
|
109
|
+
* The last notice the events stream finished sending, by its id: where a
|
|
110
|
+
* reconnect resumes. Update notices arrive as surface frames, not notices,
|
|
111
|
+
* so `state.notices` alone would miss them.
|
|
112
|
+
*/
|
|
113
|
+
let resumeAfter = 0;
|
|
98
114
|
|
|
99
115
|
// ── transport: SSE over POST (EventSource cannot POST) ──────────────
|
|
100
116
|
function enqueue(path, body) {
|
|
@@ -211,7 +227,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
211
227
|
}
|
|
212
228
|
}
|
|
213
229
|
|
|
214
|
-
|
|
230
|
+
/**
|
|
231
|
+
* `from`: when a surface in this event was rendered, at the latest, by this
|
|
232
|
+
* browser's clock, for one that no request of its own asked for.
|
|
233
|
+
*/
|
|
234
|
+
function apply(event, from = sentAt) {
|
|
215
235
|
switch (event.type) {
|
|
216
236
|
case "hello":
|
|
217
237
|
state.conversationId = event.conversationId;
|
|
@@ -227,13 +247,25 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
227
247
|
const { type, ...notice } = event;
|
|
228
248
|
state.notices.push(notice);
|
|
229
249
|
const block = { kind: "notice", id: `n:${event.seq}`, ...notice };
|
|
230
|
-
// beside its surface — after any notices already there — or at the end
|
|
231
|
-
|
|
250
|
+
// beside its surface — after any notices already there — or at the end;
|
|
251
|
+
// one naming an earlier handle of the surface goes beside it as it is now
|
|
252
|
+
const handle = event.handle === undefined ? undefined : current(event.handle);
|
|
253
|
+
const at = handle === undefined ? -1 : state.blocks.findIndex((b) => b.kind === "ui" && b.handle === handle);
|
|
232
254
|
if (at < 0) state.blocks.push(block);
|
|
233
255
|
else state.blocks.splice(afterNotices(at), 0, block);
|
|
234
256
|
break;
|
|
235
257
|
}
|
|
236
258
|
|
|
259
|
+
// A broadcast to every open browser. Never stored, so there is nothing
|
|
260
|
+
// to resume: its latest value is kept, and its handler hears it.
|
|
261
|
+
case "signal": {
|
|
262
|
+
state.signals[event.name] = event.payload;
|
|
263
|
+
// the app's own handlers only, never one an object inherits, such as `toString`
|
|
264
|
+
const handler = Object.hasOwn(signals, event.name) ? signals[event.name] : undefined;
|
|
265
|
+
if (typeof handler === "function") attempt(() => handler(event.payload, { version: event.version }));
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
|
|
237
269
|
// A stream that rejoins a wake turn is sent what the turn has shown so
|
|
238
270
|
// far: a block this client already has is brought up to date, not added
|
|
239
271
|
// again.
|
|
@@ -269,17 +301,37 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
269
301
|
|
|
270
302
|
case "ui_open": {
|
|
271
303
|
if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
|
|
304
|
+
// Replaced already: a revision on the events stream overtook the
|
|
305
|
+
// request that showed the original, held back on its way here. The
|
|
306
|
+
// surface it became stands, and takes the original's tool row; the
|
|
307
|
+
// original isn't shown beside it, and its props go nowhere.
|
|
308
|
+
if (replacedBy.has(event.handle)) {
|
|
309
|
+
const now = state.blocks.find((b) => b.kind === "ui" && b.handle === current(event.handle));
|
|
310
|
+
if (now && !now.toolId) now.toolId = event.toolId;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
272
313
|
// A revision of a surface on screen takes over its record in place:
|
|
273
314
|
// its component, element and state stay, its handle moves on, and its
|
|
274
315
|
// props stay until the new ones arrive, so the transcript keeps the
|
|
275
316
|
// same element rather than mounting afresh.
|
|
276
|
-
|
|
317
|
+
// `replaces`, or, for a revision replayed after several, an earlier
|
|
318
|
+
// handle of the same surface: whichever this browser last saw
|
|
319
|
+
const old = [event.replaces, ...(event.alsoReplaces ?? [])]
|
|
320
|
+
.filter((h) => h !== undefined)
|
|
321
|
+
.map((h) => state.surfaces.get(h))
|
|
322
|
+
.find(Boolean);
|
|
323
|
+
// every earlier handle now leads here, seen or not, so a notice naming
|
|
324
|
+
// one finds this surface
|
|
325
|
+
for (const h of [event.replaces, ...(event.alsoReplaces ?? [])]) {
|
|
326
|
+
if (h !== undefined && h !== event.handle) replacedBy.set(h, event.handle);
|
|
327
|
+
}
|
|
277
328
|
let surface;
|
|
278
329
|
if (old) {
|
|
279
|
-
|
|
330
|
+
const was = old.handle;
|
|
331
|
+
state.surfaces.delete(was);
|
|
280
332
|
Object.assign(old, { handle: event.handle, component: event.component, version: event.version, revising: true });
|
|
281
333
|
state.surfaces.set(event.handle, (surface = old));
|
|
282
|
-
const block = state.blocks.find((b) => b.kind === "ui" && b.handle ===
|
|
334
|
+
const block = state.blocks.find((b) => b.kind === "ui" && b.handle === was);
|
|
283
335
|
if (block) block.handle = event.handle;
|
|
284
336
|
} else {
|
|
285
337
|
surface = {
|
|
@@ -296,10 +348,14 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
296
348
|
state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
|
|
297
349
|
}
|
|
298
350
|
// Notices come on a stream of their own, so one can arrive before the
|
|
299
|
-
// surface it names
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
351
|
+
// surface it names, or name a handle of it this browser hadn't heard
|
|
352
|
+
// of: it went to the end then, and moves under it now, among those
|
|
353
|
+
// already beside it in the order they were sent. Moved within the
|
|
354
|
+
// same array, which the transcript would otherwise take for a new
|
|
355
|
+
// conversation's.
|
|
356
|
+
const early = state.blocks
|
|
357
|
+
.filter((b) => b.kind === "notice" && b.handle !== undefined && current(b.handle) === event.handle)
|
|
358
|
+
.sort((x, y) => x.seq - y.seq);
|
|
303
359
|
for (const notice of early) state.blocks.splice(state.blocks.indexOf(notice), 1);
|
|
304
360
|
const at = state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
|
|
305
361
|
if (at >= 0) state.blocks.splice(afterNotices(at), 0, ...early);
|
|
@@ -309,8 +365,8 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
309
365
|
// whichever is earlier. A revision brings its own, and the one it
|
|
310
366
|
// replaced stops counting.
|
|
311
367
|
if (typeof event.staleAfterMs === "number") {
|
|
312
|
-
const
|
|
313
|
-
surface.deadline =
|
|
368
|
+
const stored = typeof event.ageMs === "number" ? Math.min(from, Date.now() - event.ageMs) : from;
|
|
369
|
+
surface.deadline = stored + event.staleAfterMs;
|
|
314
370
|
surface.window = event.staleAfterMs;
|
|
315
371
|
} else {
|
|
316
372
|
surface.deadline = null;
|
|
@@ -394,7 +450,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
394
450
|
const opened = Date.now();
|
|
395
451
|
let delivered = false;
|
|
396
452
|
try {
|
|
397
|
-
const last = state.notices.at(-1)?.seq ?? 0;
|
|
453
|
+
const last = Math.max(resumeAfter, state.notices.at(-1)?.seq ?? 0);
|
|
398
454
|
const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
|
|
399
455
|
headers: last ? { "last-event-id": String(last) } : {},
|
|
400
456
|
signal,
|
|
@@ -413,13 +469,21 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
413
469
|
if (gen !== generation) return;
|
|
414
470
|
const frame = buffer.slice(0, i);
|
|
415
471
|
buffer = buffer.slice(i + 2);
|
|
416
|
-
const
|
|
472
|
+
const lines = frame.split("\n");
|
|
473
|
+
const line = lines.find((l) => l.startsWith("data: "));
|
|
417
474
|
if (line) {
|
|
418
475
|
const event = JSON.parse(line.slice(6));
|
|
419
476
|
followTurn(event);
|
|
420
|
-
|
|
477
|
+
// A revision `hai.update` made comes outside any turn, even
|
|
478
|
+
// while one is running, so no request of this browser's
|
|
479
|
+
// dates it: only its age does. No tool call made it.
|
|
480
|
+
const outside = event.type === "ui_open" && event.toolId === "";
|
|
481
|
+
apply(event, remote && !outside ? sentAt : Date.now());
|
|
421
482
|
delivered = true;
|
|
422
483
|
}
|
|
484
|
+
// the notice this frame finished, revisions' included
|
|
485
|
+
const id = Number(lines.find((l) => l.startsWith("id: "))?.slice(4));
|
|
486
|
+
if (Number.isSafeInteger(id) && id > resumeAfter) resumeAfter = id;
|
|
423
487
|
}
|
|
424
488
|
}
|
|
425
489
|
}
|
|
@@ -534,6 +598,12 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
534
598
|
return i;
|
|
535
599
|
}
|
|
536
600
|
|
|
601
|
+
/** The handle a surface has now, following every revision heard of since `handle`. */
|
|
602
|
+
function current(handle) {
|
|
603
|
+
while (replacedBy.has(handle)) handle = replacedBy.get(handle);
|
|
604
|
+
return handle;
|
|
605
|
+
}
|
|
606
|
+
|
|
537
607
|
/** The earliest deadline among the surfaces showing, and which window set it. */
|
|
538
608
|
function refreshDeadline() {
|
|
539
609
|
let soonest = null;
|
|
@@ -676,6 +746,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
676
746
|
aborter = new AbortController();
|
|
677
747
|
stopListening();
|
|
678
748
|
cutOff = false;
|
|
749
|
+
resumeAfter = 0;
|
|
679
750
|
clearTimeout(timer);
|
|
680
751
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
681
752
|
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
@@ -687,8 +758,10 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
687
758
|
expiresAt: null,
|
|
688
759
|
expired: null,
|
|
689
760
|
notices: [],
|
|
761
|
+
signals: Object.create(null),
|
|
690
762
|
});
|
|
691
763
|
state.surfaces.clear();
|
|
764
|
+
replacedBy.clear();
|
|
692
765
|
notify({ type: "reset" });
|
|
693
766
|
}
|
|
694
767
|
|
package/src/transcript.js
CHANGED
|
@@ -102,19 +102,20 @@ function toolStatus(block, chat) {
|
|
|
102
102
|
if (block.status !== "awaiting") return block.status;
|
|
103
103
|
const { blocks, surfaces, expired } = chat.state;
|
|
104
104
|
// Found once per row and kept: every answered question keeps its row
|
|
105
|
-
// `awaiting`, and this runs for each of them on every streamed event.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
// `awaiting`, and this runs for each of them on every streamed event. The
|
|
106
|
+
// surface's block is kept, not its handle, which a revision moves on.
|
|
107
|
+
let question = questions.get(block);
|
|
108
|
+
if (question === undefined) {
|
|
109
|
+
question = blocks.find(
|
|
109
110
|
(b) => b.kind === "ui" && b.toolId === block.id && surfaces.get(b.handle)?.mode === "elicit",
|
|
110
|
-
)
|
|
111
|
-
if (
|
|
111
|
+
);
|
|
112
|
+
if (question !== undefined) questions.set(block, question);
|
|
112
113
|
}
|
|
113
|
-
if (surfaces.get(handle)?.state === "frozen") return "resolved";
|
|
114
|
+
if (surfaces.get(question?.handle)?.state === "frozen") return "resolved";
|
|
114
115
|
return expired ? "expired" : "awaiting";
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
/** A tool row's block → the
|
|
118
|
+
/** A tool row's block → the block of the question it asked, once seen. */
|
|
118
119
|
const questions = new WeakMap();
|
|
119
120
|
|
|
120
121
|
const STATUS_TEXT = {
|