@haikit/client 0.12.0 → 0.13.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
@@ -86,6 +86,36 @@ unmounted, and subscribers hear `{ type: "closed" }`. `mountChat` then removes
86
86
  its shell. A page using `renderTranscript` directly calls `closeTranscript(root)`
87
87
  when it removes a transcript, so its resize observer and scroll listener let go.
88
88
 
89
+ ## Notices
90
+
91
+ When the server sends notices (`hai.notify`), its `hello` says so, and the chat
92
+ keeps `GET /hai/events` open for the conversation. A dropped connection
93
+ reconnects, backing off, and resumes after the last notice it saw, so none is
94
+ missed or shown twice. `reset()` and `close()` end it.
95
+
96
+ Each notice lands in `state.notices` and as a `notice` block: right after its
97
+ surface's block when it names one, otherwise at the end. Its component comes
98
+ from a `notices` registry, kept apart from the surface registry so the two
99
+ never clash on a name:
100
+
101
+ ```js
102
+ mountChat({
103
+ root, registry,
104
+ notices: {
105
+ hold_confirmed: {
106
+ mount(el, payload, { seq, handle }) {
107
+ el.textContent = `Fare held · ${payload.flightId} · ref ${payload.reference}`;
108
+ },
109
+ },
110
+ },
111
+ });
112
+ ```
113
+
114
+ A notice's component gets **no `send`**: nothing in it can reach the server. A
115
+ name the registry doesn't list renders an error card. Like a surface, `mount`
116
+ may return `{ unmount }`. With `createChat`, mount one yourself with
117
+ `chat.mountNotice(seq, element)`.
118
+
89
119
  ## Out-of-date conversations
90
120
 
91
121
  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.12.0",
3
+ "version": "0.13.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,8 +1,10 @@
1
- import type { Chat, Registry } from "./index.js";
1
+ import type { Chat, NoticeRegistry, Registry } from "./index.js";
2
2
 
3
3
  export interface MountChatOptions {
4
4
  root: HTMLElement;
5
5
  registry: Registry;
6
+ /** Components for the notices the server sends, by notice name. */
7
+ notices?: NoticeRegistry;
6
8
  /** Base path the server's two routes are mounted at. Default `/hai`. */
7
9
  endpoint?: string;
8
10
  title?: string;
package/src/app.js CHANGED
@@ -86,6 +86,7 @@ function dropFavicon() {
86
86
  * @param {{
87
87
  * root: HTMLElement,
88
88
  * registry: Record<string, { mount: Function }>,
89
+ * notices?: Record<string, { mount: Function }>,
89
90
  * endpoint?: string,
90
91
  * title?: string,
91
92
  * subtitle?: string,
@@ -99,6 +100,7 @@ function dropFavicon() {
99
100
  export function mountChat({
100
101
  root,
101
102
  registry,
103
+ notices,
102
104
  endpoint = "/hai",
103
105
  title = "HaiKIT",
104
106
  subtitle = "",
@@ -108,7 +110,7 @@ export function mountChat({
108
110
  emptyText = "",
109
111
  theme,
110
112
  }) {
111
- const chat = createChat({ endpoint, registry });
113
+ const chat = createChat({ endpoint, registry, notices });
112
114
  const id = `hai-${++shells}`;
113
115
 
114
116
  // Unset, the page follows the system. A pin goes on the root, where hai.css
package/src/hai.css CHANGED
@@ -415,6 +415,22 @@
415
415
  }
416
416
  .hai-shimmer:nth-child(3) { width: 82%; }
417
417
  .hai-shimmer:nth-child(4) { width: 60%; }
418
+ /* a notice: something the server said outside of any request */
419
+ .hai-notice {
420
+ margin-bottom: 18px;
421
+ overflow: hidden;
422
+ border: 1px solid var(--hai-line);
423
+ border-radius: var(--hai-radius);
424
+ background: var(--hai-panel);
425
+ }
426
+ .hai-notice-label {
427
+ padding: 6px 12px;
428
+ border-bottom: 1px solid var(--hai-line);
429
+ background: var(--hai-panel-2);
430
+ font: 11px var(--hai-mono);
431
+ color: var(--hai-dim);
432
+ }
433
+ .hai-notice > .hai-surface-error { margin: 0; border: none; border-radius: 0; }
418
434
  .hai-surface-error {
419
435
  margin-bottom: 18px;
420
436
  padding: 11px 14px;
package/src/index.d.ts CHANGED
@@ -19,7 +19,17 @@ export interface Progress {
19
19
  total?: number;
20
20
  }
21
21
 
22
- /** Blocks the server emits, plus the three the client synthesises locally. */
22
+ /** A notice the server sent, as the events stream delivered it. */
23
+ export interface NoticeRecord {
24
+ seq: number;
25
+ name: string;
26
+ version: number;
27
+ payload: unknown;
28
+ /** The surface it is shown beside. */
29
+ handle?: string;
30
+ }
31
+
32
+ /** Blocks the server emits, plus the four the client synthesises locally. */
23
33
  export type Block =
24
34
  | { kind: "user"; id: string; text: string }
25
35
  | { kind: "assistant"; id: string; text: string }
@@ -40,7 +50,9 @@ export type Block =
40
50
  /** synthesised from `error` */
41
51
  | { kind: "error"; id: string; message: string }
42
52
  /** synthesised from `expired` — the conversation is closed; offer `reset()` */
43
- | { kind: "expired"; id: string; message: string };
53
+ | { kind: "expired"; id: string; message: string }
54
+ /** synthesised from `notice` — after its surface's block when it names one, else at the end */
55
+ | ({ kind: "notice"; id: string } & NoticeRecord);
44
56
 
45
57
  /** The SSE frame shape. Narrow on `type` to get the specific payload. */
46
58
  export type WireEvent = { type: string } & Record<string, any>;
@@ -90,6 +102,27 @@ export interface ComponentDef<P = any> {
90
102
  */
91
103
  export type Registry = Record<string, ComponentDef<any>>;
92
104
 
105
+ /** What `mountNotice()` hands your notice component. There is no `send`. */
106
+ export interface NoticeMountCtx {
107
+ seq: number;
108
+ version: number;
109
+ /** The surface it is shown beside, if any. */
110
+ handle?: string;
111
+ }
112
+
113
+ /** Returned by a notice's `mount`. `unmount` is called when it goes away. */
114
+ export interface NoticeInstance {
115
+ unmount?(): void;
116
+ }
117
+
118
+ /** A notice component. `payload` is whatever the notice's `payload` schema produces. */
119
+ export interface NoticeDef<P = any> {
120
+ mount(element: HTMLElement, payload: P, ctx: NoticeMountCtx): NoticeInstance | null | void;
121
+ }
122
+
123
+ /** The notices allowlist, by notice name. Separate from `Registry`, so names never clash. */
124
+ export type NoticeRegistry = Record<string, NoticeDef<any>>;
125
+
93
126
  export interface SurfaceRecord {
94
127
  handle: string;
95
128
  component: string;
@@ -125,6 +158,8 @@ export interface ChatState {
125
158
  * until `reset()` starts a new conversation.
126
159
  */
127
160
  expired: string | null;
161
+ /** Notices the server has sent this conversation, in order. */
162
+ notices: NoticeRecord[];
128
163
  }
129
164
 
130
165
  export interface Chat {
@@ -153,6 +188,12 @@ export interface Chat {
153
188
  * instance it had before.
154
189
  */
155
190
  mount(handle: string, element: HTMLElement): SurfaceInstance | null;
191
+ /**
192
+ * Mounts the notice numbered `seq` into `element`, from the `notices`
193
+ * registry. Null if there is no such notice, or the chat is closed. An
194
+ * unregistered name renders an error card.
195
+ */
196
+ mountNotice(seq: number, element: HTMLElement): NoticeInstance | null;
156
197
  /**
157
198
  * Start a new conversation on the next send. Anything queued for the old one
158
199
  * is never sent, a request still open is aborted — the new conversation
@@ -172,4 +213,9 @@ export interface Chat {
172
213
  subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
173
214
  }
174
215
 
175
- export function createChat(options: { endpoint?: string; registry: Registry }): Chat;
216
+ /**
217
+ * When the server sends notices (its `hello` says so), the chat also keeps
218
+ * `GET {endpoint}/events` open for the conversation, resuming after the last
219
+ * notice it saw if the connection drops, until `reset()` or `close()`.
220
+ */
221
+ export function createChat(options: { endpoint?: string; registry: Registry; notices?: NoticeRegistry }): Chat;
package/src/index.js CHANGED
@@ -10,9 +10,9 @@
10
10
  */
11
11
 
12
12
  /**
13
- * @param {{endpoint?: string, registry: Record<string, {mount: Function}>}} options
13
+ * @param {{endpoint?: string, registry: Record<string, {mount: Function}>, notices?: Record<string, {mount: Function}>}} options
14
14
  */
15
- export function createChat({ endpoint = "/hai", registry }) {
15
+ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
16
16
  const listeners = new Set();
17
17
  const state = {
18
18
  conversationId: null,
@@ -31,6 +31,8 @@ export function createChat({ endpoint = "/hai", registry }) {
31
31
  expiresAt: null,
32
32
  /** The notice, once the conversation is out of date. Final until `reset()`. */
33
33
  expired: null,
34
+ /** Notices the server has sent this conversation, in the order they were sent. */
35
+ notices: [],
34
36
  };
35
37
 
36
38
  const notify = (event) => listeners.forEach((fn) => fn(state, event));
@@ -67,6 +69,12 @@ export function createChat({ endpoint = "/hai", registry }) {
67
69
  /** Set by close(): this chat is done for good, and does nothing more. */
68
70
  let shutDown = false;
69
71
 
72
+ /** The events stream's conversation while it is open, and how to stop it. */
73
+ let listening = null;
74
+ let listenAborter = null;
75
+ /** seq → the mounted notice's instance and element */
76
+ const noticeMounts = new Map();
77
+
70
78
  /**
71
79
  * When the request now streaming was sent. The server stamps every surface
72
80
  * it renders after that request arrived, so a freshness deadline counted from
@@ -172,7 +180,27 @@ export function createChat({ endpoint = "/hai", registry }) {
172
180
  case "hello":
173
181
  state.conversationId = event.conversationId;
174
182
  state.model = event.model;
183
+ if (event.events) listen(event.conversationId);
184
+ break;
185
+
186
+ // From the events stream, in seq order. A reconnect resumes after the
187
+ // last one seen, so anything not past it is one already shown, and is
188
+ // dropped: one comparison, however long the backlog.
189
+ case "notice": {
190
+ if (event.seq <= (state.notices.at(-1)?.seq ?? 0)) return;
191
+ const { type, ...notice } = event;
192
+ state.notices.push(notice);
193
+ const block = { kind: "notice", id: `n:${event.seq}`, ...notice };
194
+ // beside its surface — after any notices already there — or at the end
195
+ const at = event.handle === undefined ? -1 : state.blocks.findIndex((b) => b.id === `ui:${event.handle}`);
196
+ if (at < 0) state.blocks.push(block);
197
+ else {
198
+ let i = at + 1;
199
+ while (state.blocks[i]?.kind === "notice" && state.blocks[i].handle === event.handle) i++;
200
+ state.blocks.splice(i, 0, block);
201
+ }
175
202
  break;
203
+ }
176
204
 
177
205
  case "block_start":
178
206
  state.blocks.push({ ...event.block });
@@ -213,6 +241,14 @@ export function createChat({ endpoint = "/hai", registry }) {
213
241
  element: null,
214
242
  });
215
243
  state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
244
+ // Notices come on a stream of their own, so one can arrive before the
245
+ // surface it names: it went to the end then, and moves under it now.
246
+ // Moved within the same array, which the transcript would otherwise
247
+ // take for a new conversation's.
248
+ for (const early of state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle)) {
249
+ state.blocks.splice(state.blocks.indexOf(early), 1);
250
+ state.blocks.push(early);
251
+ }
216
252
  if (typeof event.staleAfterMs === "number") {
217
253
  const at = sentAt + event.staleAfterMs;
218
254
  if (state.expiresAt === null || at < state.expiresAt) {
@@ -266,6 +302,111 @@ export function createChat({ endpoint = "/hai", registry }) {
266
302
  notify(event);
267
303
  }
268
304
 
305
+ // ── notices ────────────────────────────────────────────────────────
306
+ // A GET that stays open, read the same way as a turn's stream. It ends when
307
+ // the conversation does — reset() or close() — and otherwise reconnects,
308
+ // resuming after the last notice it saw. An out-of-date conversation keeps
309
+ // it: a confirmation is still true once fares have gone stale.
310
+
311
+ function listen(conversationId) {
312
+ if (listening === conversationId || shutDown) return;
313
+ listenAborter?.abort();
314
+ listenAborter = new AbortController();
315
+ listening = conversationId;
316
+ void follow(conversationId, generation, listenAborter.signal);
317
+ }
318
+
319
+ async function follow(conversationId, gen, signal) {
320
+ let wait = 1_000;
321
+ while (gen === generation && !signal.aborted) {
322
+ // A connection that delivered a notice, or stayed up a while, was
323
+ // healthy, and the next drop starts the backoff over. One that drops
324
+ // straight after it opens was not, however often the server says 200.
325
+ const opened = Date.now();
326
+ let delivered = false;
327
+ try {
328
+ const last = state.notices.at(-1)?.seq ?? 0;
329
+ const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
330
+ headers: last ? { "last-event-id": String(last) } : {},
331
+ signal,
332
+ });
333
+ // the server has no such conversation, or sends no notices: stop
334
+ if (res.status === 404) return;
335
+ if (res.ok) {
336
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
337
+ let buffer = "";
338
+ for (;;) {
339
+ const { value, done } = await reader.read();
340
+ if (done) break;
341
+ buffer += value;
342
+ let i;
343
+ while ((i = buffer.indexOf("\n\n")) >= 0) {
344
+ if (gen !== generation) return;
345
+ const frame = buffer.slice(0, i);
346
+ buffer = buffer.slice(i + 2);
347
+ const line = frame.split("\n").find((l) => l.startsWith("data: "));
348
+ if (line) {
349
+ apply(JSON.parse(line.slice(6)));
350
+ delivered = true;
351
+ }
352
+ }
353
+ }
354
+ }
355
+ } catch {
356
+ if (signal.aborted) return;
357
+ }
358
+ if (delivered || Date.now() - opened >= HEALTHY_MS) wait = 1_000;
359
+ // Dropped, refused, or the network went away: try again, backing off.
360
+ // reset() or close() ends the wait at once, timer and all.
361
+ await new Promise((resolve) => {
362
+ const done = () => {
363
+ clearTimeout(timer);
364
+ signal.removeEventListener("abort", done);
365
+ resolve();
366
+ };
367
+ const timer = setTimeout(done, wait);
368
+ signal.addEventListener("abort", done);
369
+ });
370
+ wait = Math.min(wait * 2, 30_000);
371
+ }
372
+ }
373
+
374
+ function stopListening() {
375
+ listenAborter?.abort();
376
+ listenAborter = null;
377
+ listening = null;
378
+ }
379
+
380
+ /**
381
+ * Mount a notice into an element, from the `notices` registry. As with
382
+ * surfaces, a name the registry doesn't list renders an error card, never
383
+ * an improvised UI. A notice has no `send`: it cannot reach the server.
384
+ */
385
+ function mountNotice(seq, element) {
386
+ const notice = state.notices.find((n) => n.seq === seq);
387
+ if (shutDown || !notice) return null;
388
+ unmountNotice(seq);
389
+
390
+ const definition = notices[notice.name];
391
+ if (!definition) {
392
+ element.textContent = `unknown notice: ${notice.name}`;
393
+ element.className = "hai-surface-error";
394
+ return null;
395
+ }
396
+ element.replaceChildren();
397
+ // a component may return nothing; the promise is an instance or null
398
+ const instance =
399
+ definition.mount(element, notice.payload, { seq, version: notice.version, handle: notice.handle }) ?? null;
400
+ noticeMounts.set(seq, instance);
401
+ return instance;
402
+ }
403
+
404
+ function unmountNotice(seq) {
405
+ const instance = noticeMounts.get(seq);
406
+ noticeMounts.delete(seq);
407
+ attempt(() => instance?.unmount?.());
408
+ }
409
+
269
410
  // ── expiry ─────────────────────────────────────────────────────────
270
411
  // The server refuses every request on an out-of-date conversation. Knowing
271
412
  // the deadline here too means a tab left open greys out on time, rather than
@@ -409,8 +550,10 @@ export function createChat({ endpoint = "/hai", registry }) {
409
550
  generation++;
410
551
  aborter.abort();
411
552
  aborter = new AbortController();
553
+ stopListening();
412
554
  clearTimeout(timer);
413
555
  for (const surface of state.surfaces.values()) unmount(surface);
556
+ for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
414
557
  Object.assign(state, {
415
558
  conversationId: null,
416
559
  status: "idle",
@@ -418,6 +561,7 @@ export function createChat({ endpoint = "/hai", registry }) {
418
561
  context: { messages: [], modelTokens: 0, uiTokens: 0 },
419
562
  expiresAt: null,
420
563
  expired: null,
564
+ notices: [],
421
565
  });
422
566
  state.surfaces.clear();
423
567
  notify({ type: "reset" });
@@ -434,8 +578,10 @@ export function createChat({ endpoint = "/hai", registry }) {
434
578
  shutDown = true;
435
579
  generation++;
436
580
  aborter.abort();
581
+ stopListening();
437
582
  clearTimeout(timer);
438
583
  for (const surface of state.surfaces.values()) unmount(surface);
584
+ for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
439
585
  // Every subscriber hears it, even after one that throws, and none is held
440
586
  // on to: they are let go of before they are told.
441
587
  const subscribers = [...listeners];
@@ -449,6 +595,7 @@ export function createChat({ endpoint = "/hai", registry }) {
449
595
  send,
450
596
  interact,
451
597
  mount,
598
+ mountNotice,
452
599
  reset,
453
600
  close,
454
601
  subscribe(fn) {
@@ -460,6 +607,9 @@ export function createChat({ endpoint = "/hai", registry }) {
460
607
  };
461
608
  }
462
609
 
610
+ /** An events connection that stays up this long was healthy, whatever it delivered. */
611
+ const HEALTHY_MS = 30_000;
612
+
463
613
  /**
464
614
  * Run `fn`, reporting what it throws without letting it stop the caller: the
465
615
  * error surfaces as an uncaught one would, after the caller has finished.
package/src/transcript.js CHANGED
@@ -376,6 +376,20 @@ function renderBlock(block, chat) {
376
376
  return el;
377
377
  }
378
378
 
379
+ // Something the server told this conversation outside of any request. The
380
+ // label says what kind; the app's component, from the notices registry,
381
+ // draws the rest. A status region, so a screen reader announces it.
382
+ case "notice": {
383
+ const el = h("div", "hai-notice");
384
+ el.setAttribute("role", "status");
385
+ el.dataset.notice = block.name;
386
+ el.append(h("div", "hai-notice-label", `notice · ${block.name}`));
387
+ const body = h("div", "hai-notice-body");
388
+ el.append(body);
389
+ queueMicrotask(() => chat.mountNotice(block.seq, body));
390
+ return el;
391
+ }
392
+
379
393
  default:
380
394
  return h("div", "hai-block", JSON.stringify(block));
381
395
  }