@haikit/client 0.12.0 → 0.14.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 +37 -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 +233 -4
- package/src/transcript.js +14 -0
package/README.md
CHANGED
|
@@ -86,6 +86,43 @@ 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 `wake` notice's turn comes down the same stream: its reply appears under the
|
|
115
|
+
notice like any other. While it runs, `send` and `interact` wait for it to
|
|
116
|
+
finish rather than being refused, just as they wait behind this chat's own
|
|
117
|
+
requests. If the events connection drops mid-turn, the turn runs on without
|
|
118
|
+
a way to say when it ends, so the next request keeps trying, backing off, for
|
|
119
|
+
up to two minutes rather than giving up after one retry.
|
|
120
|
+
|
|
121
|
+
A notice's component gets **no `send`**: nothing in it can reach the server. A
|
|
122
|
+
name the registry doesn't list renders an error card. Like a surface, `mount`
|
|
123
|
+
may return `{ unmount }`. With `createChat`, mount one yourself with
|
|
124
|
+
`chat.mountNotice(seq, element)`.
|
|
125
|
+
|
|
89
126
|
## Out-of-date conversations
|
|
90
127
|
|
|
91
128
|
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,25 @@ 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
|
+
/**
|
|
76
|
+
* Settles when the turn a wake notice started, streaming on the events
|
|
77
|
+
* stream, is done. Until then nothing is posted: the server holds the
|
|
78
|
+
* conversation for that turn, and a message sent now would only be told 409.
|
|
79
|
+
*/
|
|
80
|
+
let remote = null;
|
|
81
|
+
let endRemote = () => {};
|
|
82
|
+
/**
|
|
83
|
+
* Set when the events connection dropped while a wake turn ran. That turn
|
|
84
|
+
* still holds the conversation on the server, and its `released` is lost
|
|
85
|
+
* with the connection, so the next request is given longer to get in.
|
|
86
|
+
*/
|
|
87
|
+
let cutOff = false;
|
|
88
|
+
/** seq → the mounted notice's instance and element */
|
|
89
|
+
const noticeMounts = new Map();
|
|
90
|
+
|
|
70
91
|
/**
|
|
71
92
|
* When the request now streaming was sent. The server stamps every surface
|
|
72
93
|
* it renders after that request arrived, so a freshness deadline counted from
|
|
@@ -109,6 +130,11 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
109
130
|
// The deadline is checked again here, not only when queued: a long turn
|
|
110
131
|
// ahead of this request can run past it while it waits.
|
|
111
132
|
if (gen !== generation || closed()) return;
|
|
133
|
+
// queued behind a wake turn, as behind one of this client's own
|
|
134
|
+
while (remote) {
|
|
135
|
+
await remote;
|
|
136
|
+
if (gen !== generation || closed()) return;
|
|
137
|
+
}
|
|
112
138
|
sentAt = Date.now();
|
|
113
139
|
let res = await post(path, body, signal);
|
|
114
140
|
|
|
@@ -120,8 +146,26 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
120
146
|
await new Promise((r) => setTimeout(r, 150));
|
|
121
147
|
// the deadline can pass during the wait, and reset() or close() come
|
|
122
148
|
if (gen !== generation || closed()) return;
|
|
149
|
+
// A wake turn may be what holds it, and have said so meanwhile: wait it
|
|
150
|
+
// out, as if it had said so before this request went.
|
|
151
|
+
while (remote) {
|
|
152
|
+
await remote;
|
|
153
|
+
if (gen !== generation || closed()) return;
|
|
154
|
+
}
|
|
123
155
|
res = await post(path, body, signal); // rejects at once if reset() aborted it meanwhile
|
|
124
156
|
}
|
|
157
|
+
// A wake turn this client lost sight of holds the conversation until it
|
|
158
|
+
// ends, which may be a while: keep trying, backing off, for as long as a
|
|
159
|
+
// turn can hold it before its lease lapses.
|
|
160
|
+
if (res.status === 409 && cutOff) {
|
|
161
|
+
const giveUp = Date.now() + CUT_OFF_MS;
|
|
162
|
+
for (let wait = 250; res.status === 409 && Date.now() < giveUp; wait = Math.min(wait * 2, 2_000)) {
|
|
163
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
164
|
+
if (gen !== generation || closed()) return;
|
|
165
|
+
res = await post(path, body, signal);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (res.status !== 409) cutOff = false;
|
|
125
169
|
// Anything but a second 409 means the server took the request up itself —
|
|
126
170
|
// even an error — unless the stream says `expired` below. A 409 means it
|
|
127
171
|
// turned the message away unread.
|
|
@@ -172,11 +216,37 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
172
216
|
case "hello":
|
|
173
217
|
state.conversationId = event.conversationId;
|
|
174
218
|
state.model = event.model;
|
|
219
|
+
if (event.events) listen(event.conversationId);
|
|
220
|
+
break;
|
|
221
|
+
|
|
222
|
+
// From the events stream, in seq order. A reconnect resumes after the
|
|
223
|
+
// last one seen, so anything not past it is one already shown, and is
|
|
224
|
+
// dropped: one comparison, however long the backlog.
|
|
225
|
+
case "notice": {
|
|
226
|
+
if (event.seq <= (state.notices.at(-1)?.seq ?? 0)) return;
|
|
227
|
+
const { type, ...notice } = event;
|
|
228
|
+
state.notices.push(notice);
|
|
229
|
+
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.id === `ui:${event.handle}`);
|
|
232
|
+
if (at < 0) state.blocks.push(block);
|
|
233
|
+
else {
|
|
234
|
+
let i = at + 1;
|
|
235
|
+
while (state.blocks[i]?.kind === "notice" && state.blocks[i].handle === event.handle) i++;
|
|
236
|
+
state.blocks.splice(i, 0, block);
|
|
237
|
+
}
|
|
175
238
|
break;
|
|
239
|
+
}
|
|
176
240
|
|
|
177
|
-
|
|
178
|
-
|
|
241
|
+
// A stream that rejoins a wake turn is sent what the turn has shown so
|
|
242
|
+
// far: a block this client already has is brought up to date, not added
|
|
243
|
+
// again.
|
|
244
|
+
case "block_start": {
|
|
245
|
+
const known = state.blocks.find((b) => b.id === event.block.id);
|
|
246
|
+
if (known) Object.assign(known, event.block);
|
|
247
|
+
else state.blocks.push({ ...event.block });
|
|
179
248
|
break;
|
|
249
|
+
}
|
|
180
250
|
|
|
181
251
|
case "text_delta": {
|
|
182
252
|
const block = state.blocks.find((b) => b.id === event.id);
|
|
@@ -202,6 +272,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
202
272
|
}
|
|
203
273
|
|
|
204
274
|
case "ui_open":
|
|
275
|
+
if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
|
|
205
276
|
state.surfaces.set(event.handle, {
|
|
206
277
|
handle: event.handle,
|
|
207
278
|
component: event.component,
|
|
@@ -213,6 +284,14 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
213
284
|
element: null,
|
|
214
285
|
});
|
|
215
286
|
state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
|
|
287
|
+
// Notices come on a stream of their own, so one can arrive before the
|
|
288
|
+
// surface it names: it went to the end then, and moves under it now.
|
|
289
|
+
// Moved within the same array, which the transcript would otherwise
|
|
290
|
+
// take for a new conversation's.
|
|
291
|
+
for (const early of state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle)) {
|
|
292
|
+
state.blocks.splice(state.blocks.indexOf(early), 1);
|
|
293
|
+
state.blocks.push(early);
|
|
294
|
+
}
|
|
216
295
|
if (typeof event.staleAfterMs === "number") {
|
|
217
296
|
const at = sentAt + event.staleAfterMs;
|
|
218
297
|
if (state.expiresAt === null || at < state.expiresAt) {
|
|
@@ -266,6 +345,139 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
266
345
|
notify(event);
|
|
267
346
|
}
|
|
268
347
|
|
|
348
|
+
// ── notices ────────────────────────────────────────────────────────
|
|
349
|
+
// A GET that stays open, read the same way as a turn's stream. It ends when
|
|
350
|
+
// the conversation does — reset() or close() — and otherwise reconnects,
|
|
351
|
+
// resuming after the last notice it saw. An out-of-date conversation keeps
|
|
352
|
+
// it: a confirmation is still true once fares have gone stale.
|
|
353
|
+
|
|
354
|
+
function listen(conversationId) {
|
|
355
|
+
if (listening === conversationId || shutDown) return;
|
|
356
|
+
listenAborter?.abort();
|
|
357
|
+
listenAborter = new AbortController();
|
|
358
|
+
listening = conversationId;
|
|
359
|
+
void follow(conversationId, generation, listenAborter.signal);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function follow(conversationId, gen, signal) {
|
|
363
|
+
let wait = 1_000;
|
|
364
|
+
while (gen === generation && !signal.aborted) {
|
|
365
|
+
// A connection that delivered a notice, or stayed up a while, was
|
|
366
|
+
// healthy, and the next drop starts the backoff over. One that drops
|
|
367
|
+
// straight after it opens was not, however often the server says 200.
|
|
368
|
+
const opened = Date.now();
|
|
369
|
+
let delivered = false;
|
|
370
|
+
try {
|
|
371
|
+
const last = state.notices.at(-1)?.seq ?? 0;
|
|
372
|
+
const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
|
|
373
|
+
headers: last ? { "last-event-id": String(last) } : {},
|
|
374
|
+
signal,
|
|
375
|
+
});
|
|
376
|
+
// the server has no such conversation, or sends no notices: stop
|
|
377
|
+
if (res.status === 404) return;
|
|
378
|
+
if (res.ok) {
|
|
379
|
+
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
380
|
+
let buffer = "";
|
|
381
|
+
for (;;) {
|
|
382
|
+
const { value, done } = await reader.read();
|
|
383
|
+
if (done) break;
|
|
384
|
+
buffer += value;
|
|
385
|
+
let i;
|
|
386
|
+
while ((i = buffer.indexOf("\n\n")) >= 0) {
|
|
387
|
+
if (gen !== generation) return;
|
|
388
|
+
const frame = buffer.slice(0, i);
|
|
389
|
+
buffer = buffer.slice(i + 2);
|
|
390
|
+
const line = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
391
|
+
if (line) {
|
|
392
|
+
const event = JSON.parse(line.slice(6));
|
|
393
|
+
followTurn(event);
|
|
394
|
+
apply(event);
|
|
395
|
+
delivered = true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
if (signal.aborted) return finishRemote();
|
|
402
|
+
}
|
|
403
|
+
// A turn cut off with its connection runs on, but nothing will say when
|
|
404
|
+
// it ends: stop waiting for it, and give the next request longer instead.
|
|
405
|
+
if (remote) cutOff = true;
|
|
406
|
+
finishRemote();
|
|
407
|
+
if (delivered || Date.now() - opened >= HEALTHY_MS) wait = 1_000;
|
|
408
|
+
// Dropped, refused, or the network went away: try again, backing off.
|
|
409
|
+
// reset() or close() ends the wait at once, timer and all.
|
|
410
|
+
await new Promise((resolve) => {
|
|
411
|
+
const done = () => {
|
|
412
|
+
clearTimeout(timer);
|
|
413
|
+
signal.removeEventListener("abort", done);
|
|
414
|
+
resolve();
|
|
415
|
+
};
|
|
416
|
+
const timer = setTimeout(done, wait);
|
|
417
|
+
signal.addEventListener("abort", done);
|
|
418
|
+
});
|
|
419
|
+
wait = Math.min(wait * 2, 30_000);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Track a wake turn on the events stream: it starts with a `streaming`
|
|
425
|
+
* status, and is done with `released`, which the server sends only once it
|
|
426
|
+
* has saved the conversation and let it go. Its `idle` status comes before
|
|
427
|
+
* that save, so a message sent on it would still find the conversation held.
|
|
428
|
+
*/
|
|
429
|
+
function followTurn(event) {
|
|
430
|
+
if (event.type === "status" && event.status === "streaming") {
|
|
431
|
+
if (!remote) remote = new Promise((resolve) => (endRemote = resolve));
|
|
432
|
+
// surfaces it shows are stamped after this, as a request's are after it is sent
|
|
433
|
+
sentAt = Date.now();
|
|
434
|
+
} else if (event.type === "released") {
|
|
435
|
+
finishRemote();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function finishRemote() {
|
|
440
|
+
remote = null;
|
|
441
|
+
endRemote();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function stopListening() {
|
|
445
|
+
finishRemote();
|
|
446
|
+
listenAborter?.abort();
|
|
447
|
+
listenAborter = null;
|
|
448
|
+
listening = null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Mount a notice into an element, from the `notices` registry. As with
|
|
453
|
+
* surfaces, a name the registry doesn't list renders an error card, never
|
|
454
|
+
* an improvised UI. A notice has no `send`: it cannot reach the server.
|
|
455
|
+
*/
|
|
456
|
+
function mountNotice(seq, element) {
|
|
457
|
+
const notice = state.notices.find((n) => n.seq === seq);
|
|
458
|
+
if (shutDown || !notice) return null;
|
|
459
|
+
unmountNotice(seq);
|
|
460
|
+
|
|
461
|
+
const definition = notices[notice.name];
|
|
462
|
+
if (!definition) {
|
|
463
|
+
element.textContent = `unknown notice: ${notice.name}`;
|
|
464
|
+
element.className = "hai-surface-error";
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
element.replaceChildren();
|
|
468
|
+
// a component may return nothing; the promise is an instance or null
|
|
469
|
+
const instance =
|
|
470
|
+
definition.mount(element, notice.payload, { seq, version: notice.version, handle: notice.handle }) ?? null;
|
|
471
|
+
noticeMounts.set(seq, instance);
|
|
472
|
+
return instance;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function unmountNotice(seq) {
|
|
476
|
+
const instance = noticeMounts.get(seq);
|
|
477
|
+
noticeMounts.delete(seq);
|
|
478
|
+
attempt(() => instance?.unmount?.());
|
|
479
|
+
}
|
|
480
|
+
|
|
269
481
|
// ── expiry ─────────────────────────────────────────────────────────
|
|
270
482
|
// The server refuses every request on an out-of-date conversation. Knowing
|
|
271
483
|
// the deadline here too means a tab left open greys out on time, rather than
|
|
@@ -409,8 +621,11 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
409
621
|
generation++;
|
|
410
622
|
aborter.abort();
|
|
411
623
|
aborter = new AbortController();
|
|
624
|
+
stopListening();
|
|
625
|
+
cutOff = false;
|
|
412
626
|
clearTimeout(timer);
|
|
413
627
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
628
|
+
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
414
629
|
Object.assign(state, {
|
|
415
630
|
conversationId: null,
|
|
416
631
|
status: "idle",
|
|
@@ -418,6 +633,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
418
633
|
context: { messages: [], modelTokens: 0, uiTokens: 0 },
|
|
419
634
|
expiresAt: null,
|
|
420
635
|
expired: null,
|
|
636
|
+
notices: [],
|
|
421
637
|
});
|
|
422
638
|
state.surfaces.clear();
|
|
423
639
|
notify({ type: "reset" });
|
|
@@ -434,8 +650,10 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
434
650
|
shutDown = true;
|
|
435
651
|
generation++;
|
|
436
652
|
aborter.abort();
|
|
653
|
+
stopListening();
|
|
437
654
|
clearTimeout(timer);
|
|
438
655
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
656
|
+
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
439
657
|
// Every subscriber hears it, even after one that throws, and none is held
|
|
440
658
|
// on to: they are let go of before they are told.
|
|
441
659
|
const subscribers = [...listeners];
|
|
@@ -449,6 +667,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
449
667
|
send,
|
|
450
668
|
interact,
|
|
451
669
|
mount,
|
|
670
|
+
mountNotice,
|
|
452
671
|
reset,
|
|
453
672
|
close,
|
|
454
673
|
subscribe(fn) {
|
|
@@ -460,6 +679,16 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
460
679
|
};
|
|
461
680
|
}
|
|
462
681
|
|
|
682
|
+
/**
|
|
683
|
+
* How long a request keeps trying a conversation held by a wake turn whose
|
|
684
|
+
* events connection dropped: the default lease, the longest that turn can
|
|
685
|
+
* hold it.
|
|
686
|
+
*/
|
|
687
|
+
const CUT_OFF_MS = 120_000;
|
|
688
|
+
|
|
689
|
+
/** An events connection that stays up this long was healthy, whatever it delivered. */
|
|
690
|
+
const HEALTHY_MS = 30_000;
|
|
691
|
+
|
|
463
692
|
/**
|
|
464
693
|
* Run `fn`, reporting what it throws without letting it stop the caller: the
|
|
465
694
|
* 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
|
}
|