@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "The haikit browser runtime: streams tool payloads into interactive UI surfaces. Plain ESM, no build step, no dependencies.",
5
5
  "keywords": [
6
6
  "haikit",
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: { endpoint?: string; registry: Registry; notices?: NoticeRegistry }): Chat;
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
- function apply(event) {
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
- const at = event.handle === undefined ? -1 : state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
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
- const old = event.replaces === undefined ? undefined : state.surfaces.get(event.replaces);
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
- state.surfaces.delete(old.handle);
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 === event.replaces);
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: it went to the end then, and moves under it now.
300
- // Moved within the same array, which the transcript would otherwise
301
- // take for a new conversation's.
302
- const early = state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle);
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 from = typeof event.ageMs === "number" ? Math.min(sentAt, Date.now() - event.ageMs) : sentAt;
313
- surface.deadline = from + event.staleAfterMs;
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 line = frame.split("\n").find((l) => l.startsWith("data: "));
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
- apply(event);
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
- let handle = questions.get(block);
107
- if (handle === undefined) {
108
- handle = blocks.find(
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
- )?.handle;
111
- if (handle !== undefined) questions.set(block, handle);
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 handle of the question it asked, once seen. */
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 = {