@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 +30 -0
- package/package.json +1 -1
- package/src/app.d.ts +3 -1
- package/src/app.js +3 -1
- package/src/hai.css +16 -0
- package/src/index.d.ts +49 -3
- package/src/index.js +152 -2
- package/src/transcript.js +14 -0
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
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|