@haikit/client 0.11.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 +36 -1
- package/src/index.d.ts +67 -4
- package/src/index.js +163 -2
- package/src/transcript.js +74 -3
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
|
@@ -327,7 +327,26 @@
|
|
|
327
327
|
font: 12px var(--hai-mono);
|
|
328
328
|
color: var(--hai-dim);
|
|
329
329
|
}
|
|
330
|
-
.hai-tstatus {
|
|
330
|
+
.hai-tstatus {
|
|
331
|
+
max-width: 50%;
|
|
332
|
+
overflow: hidden;
|
|
333
|
+
text-overflow: ellipsis;
|
|
334
|
+
color: var(--hai-dim);
|
|
335
|
+
white-space: nowrap;
|
|
336
|
+
font-variant-numeric: tabular-nums;
|
|
337
|
+
}
|
|
338
|
+
/* a running tool's progress: the message gives way before the count does */
|
|
339
|
+
.hai-tprogress { display: flex; min-width: 0; white-space: pre; }
|
|
340
|
+
.hai-tmessage { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
341
|
+
.hai-tcount { flex: none; }
|
|
342
|
+
/* …and a bar along the bottom edge of its row */
|
|
343
|
+
.hai-tool summary:has(.hai-tbar) { position: relative; }
|
|
344
|
+
.hai-tbar {
|
|
345
|
+
position: absolute;
|
|
346
|
+
inset: auto 0 0 0;
|
|
347
|
+
height: 2px;
|
|
348
|
+
background: linear-gradient(to right, var(--hai-accent) var(--hai-progress, 0%), transparent 0);
|
|
349
|
+
}
|
|
331
350
|
.hai-chevron { width: 14px; height: 14px; color: var(--hai-dim); transition: transform .15s; }
|
|
332
351
|
.hai-tool[open] .hai-chevron { transform: rotate(90deg); }
|
|
333
352
|
|
|
@@ -396,6 +415,22 @@
|
|
|
396
415
|
}
|
|
397
416
|
.hai-shimmer:nth-child(3) { width: 82%; }
|
|
398
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; }
|
|
399
434
|
.hai-surface-error {
|
|
400
435
|
margin-bottom: 18px;
|
|
401
436
|
padding: 11px 14px;
|
package/src/index.d.ts
CHANGED
|
@@ -12,18 +12,47 @@ export type ConversationStatus = "idle" | "streaming" | "awaiting";
|
|
|
12
12
|
export type SurfaceMode = "display" | "elicit";
|
|
13
13
|
export type SurfaceState = "live" | "frozen";
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** How far a running tool has got, as its `ctx.progress` calls said. */
|
|
16
|
+
export interface Progress {
|
|
17
|
+
message?: string;
|
|
18
|
+
done?: number;
|
|
19
|
+
total?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
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. */
|
|
16
33
|
export type Block =
|
|
17
34
|
| { kind: "user"; id: string; text: string }
|
|
18
35
|
| { kind: "assistant"; id: string; text: string }
|
|
19
|
-
| {
|
|
36
|
+
| {
|
|
37
|
+
kind: "tool";
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
input: unknown;
|
|
41
|
+
status: string;
|
|
42
|
+
ms?: number;
|
|
43
|
+
result?: string;
|
|
44
|
+
/** What the tool's `ctx.progress` calls have said so far, merged. */
|
|
45
|
+
progress?: Progress;
|
|
46
|
+
}
|
|
20
47
|
| { kind: "interaction"; id: string; handle: string; label: string }
|
|
21
48
|
/** synthesised from `ui_open` — the slot a surface mounts into */
|
|
22
49
|
| { kind: "ui"; id: string; handle: string; toolId: string }
|
|
23
50
|
/** synthesised from `error` */
|
|
24
51
|
| { kind: "error"; id: string; message: string }
|
|
25
52
|
/** synthesised from `expired` — the conversation is closed; offer `reset()` */
|
|
26
|
-
| { 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);
|
|
27
56
|
|
|
28
57
|
/** The SSE frame shape. Narrow on `type` to get the specific payload. */
|
|
29
58
|
export type WireEvent = { type: string } & Record<string, any>;
|
|
@@ -73,6 +102,27 @@ export interface ComponentDef<P = any> {
|
|
|
73
102
|
*/
|
|
74
103
|
export type Registry = Record<string, ComponentDef<any>>;
|
|
75
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
|
+
|
|
76
126
|
export interface SurfaceRecord {
|
|
77
127
|
handle: string;
|
|
78
128
|
component: string;
|
|
@@ -108,6 +158,8 @@ export interface ChatState {
|
|
|
108
158
|
* until `reset()` starts a new conversation.
|
|
109
159
|
*/
|
|
110
160
|
expired: string | null;
|
|
161
|
+
/** Notices the server has sent this conversation, in order. */
|
|
162
|
+
notices: NoticeRecord[];
|
|
111
163
|
}
|
|
112
164
|
|
|
113
165
|
export interface Chat {
|
|
@@ -136,6 +188,12 @@ export interface Chat {
|
|
|
136
188
|
* instance it had before.
|
|
137
189
|
*/
|
|
138
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;
|
|
139
197
|
/**
|
|
140
198
|
* Start a new conversation on the next send. Anything queued for the old one
|
|
141
199
|
* is never sent, a request still open is aborted — the new conversation
|
|
@@ -155,4 +213,9 @@ export interface Chat {
|
|
|
155
213
|
subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
|
|
156
214
|
}
|
|
157
215
|
|
|
158
|
-
|
|
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,8 +180,28 @@ 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);
|
|
175
184
|
break;
|
|
176
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
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
177
205
|
case "block_start":
|
|
178
206
|
state.blocks.push({ ...event.block });
|
|
179
207
|
break;
|
|
@@ -190,6 +218,17 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
190
218
|
break;
|
|
191
219
|
}
|
|
192
220
|
|
|
221
|
+
// A frame carries only the fields its call named, so it is merged: a
|
|
222
|
+
// tool says what it is doing once, then counts.
|
|
223
|
+
case "progress": {
|
|
224
|
+
const block = state.blocks.find((b) => b.id === event.toolId);
|
|
225
|
+
if (block?.kind === "tool") {
|
|
226
|
+
const { type, toolId, ...fields } = event;
|
|
227
|
+
block.progress = { ...block.progress, ...fields };
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
|
|
193
232
|
case "ui_open":
|
|
194
233
|
state.surfaces.set(event.handle, {
|
|
195
234
|
handle: event.handle,
|
|
@@ -202,6 +241,14 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
202
241
|
element: null,
|
|
203
242
|
});
|
|
204
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
|
+
}
|
|
205
252
|
if (typeof event.staleAfterMs === "number") {
|
|
206
253
|
const at = sentAt + event.staleAfterMs;
|
|
207
254
|
if (state.expiresAt === null || at < state.expiresAt) {
|
|
@@ -255,6 +302,111 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
255
302
|
notify(event);
|
|
256
303
|
}
|
|
257
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
|
+
|
|
258
410
|
// ── expiry ─────────────────────────────────────────────────────────
|
|
259
411
|
// The server refuses every request on an out-of-date conversation. Knowing
|
|
260
412
|
// the deadline here too means a tab left open greys out on time, rather than
|
|
@@ -398,8 +550,10 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
398
550
|
generation++;
|
|
399
551
|
aborter.abort();
|
|
400
552
|
aborter = new AbortController();
|
|
553
|
+
stopListening();
|
|
401
554
|
clearTimeout(timer);
|
|
402
555
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
556
|
+
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
403
557
|
Object.assign(state, {
|
|
404
558
|
conversationId: null,
|
|
405
559
|
status: "idle",
|
|
@@ -407,6 +561,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
407
561
|
context: { messages: [], modelTokens: 0, uiTokens: 0 },
|
|
408
562
|
expiresAt: null,
|
|
409
563
|
expired: null,
|
|
564
|
+
notices: [],
|
|
410
565
|
});
|
|
411
566
|
state.surfaces.clear();
|
|
412
567
|
notify({ type: "reset" });
|
|
@@ -423,8 +578,10 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
423
578
|
shutDown = true;
|
|
424
579
|
generation++;
|
|
425
580
|
aborter.abort();
|
|
581
|
+
stopListening();
|
|
426
582
|
clearTimeout(timer);
|
|
427
583
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
584
|
+
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
428
585
|
// Every subscriber hears it, even after one that throws, and none is held
|
|
429
586
|
// on to: they are let go of before they are told.
|
|
430
587
|
const subscribers = [...listeners];
|
|
@@ -438,6 +595,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
438
595
|
send,
|
|
439
596
|
interact,
|
|
440
597
|
mount,
|
|
598
|
+
mountNotice,
|
|
441
599
|
reset,
|
|
442
600
|
close,
|
|
443
601
|
subscribe(fn) {
|
|
@@ -449,6 +607,9 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
449
607
|
};
|
|
450
608
|
}
|
|
451
609
|
|
|
610
|
+
/** An events connection that stays up this long was healthy, whatever it delivered. */
|
|
611
|
+
const HEALTHY_MS = 30_000;
|
|
612
|
+
|
|
452
613
|
/**
|
|
453
614
|
* Run `fn`, reporting what it throws without letting it stop the caller: the
|
|
454
615
|
* error surfaces as an uncaught one would, after the caller has finished.
|
package/src/transcript.js
CHANGED
|
@@ -46,8 +46,13 @@ export function renderTranscript(root, chat) {
|
|
|
46
46
|
el = renderBlock(block, chat);
|
|
47
47
|
// an expanded tool row stays expanded when its status changes
|
|
48
48
|
if (before?.el.open) el.open = true;
|
|
49
|
+
} else if (block.progress !== before.progress) {
|
|
50
|
+
// Progress changes the row in place. Rebuilt for every frame, a row a
|
|
51
|
+
// keyboard user is on would take their focus up to ten times a second.
|
|
52
|
+
const parts = rowParts.get(el);
|
|
53
|
+
if (parts) showStatus(parts, block, toolStatus(block, chat));
|
|
49
54
|
}
|
|
50
|
-
shown.set(block.id, { el, look });
|
|
55
|
+
shown.set(block.id, { el, look, progress: block.progress });
|
|
51
56
|
return el;
|
|
52
57
|
});
|
|
53
58
|
follow.shown = shown;
|
|
@@ -77,7 +82,8 @@ export function closeTranscript(root) {
|
|
|
77
82
|
function appearance(block, chat) {
|
|
78
83
|
// a surface shows a skeleton until its props arrive, and then itself for good
|
|
79
84
|
if (block.kind === "ui") return chat.state.surfaces.get(block.handle)?.props ? "mounted" : "loading";
|
|
80
|
-
|
|
85
|
+
// progress is left out: it changes the row in place (see renderTranscript)
|
|
86
|
+
if (block.kind === "tool") return JSON.stringify({ ...block, progress: undefined, status: toolStatus(block, chat) });
|
|
81
87
|
return JSON.stringify(block);
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -116,6 +122,46 @@ const STATUS_TEXT = {
|
|
|
116
122
|
error: (ms) => `failed · ${ms} ms`,
|
|
117
123
|
};
|
|
118
124
|
|
|
125
|
+
/** "2/4", or "2" when the tool hasn't said how many in all. */
|
|
126
|
+
function progressCount({ done, total }) {
|
|
127
|
+
return total !== undefined ? `${done ?? 0}/${total}` : done !== undefined ? String(done) : "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A tool row's element → its status text and progress bar, which progress changes in place. */
|
|
131
|
+
const rowParts = new WeakMap();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fill in what a tool row says of its call. While it runs, that is its
|
|
135
|
+
* progress once it has reported some: the message gives way first on a narrow
|
|
136
|
+
* row, so the count stays readable, and the bar fills once there is a total.
|
|
137
|
+
*/
|
|
138
|
+
function showStatus({ status: text, bar }, block, status) {
|
|
139
|
+
const progress = status === "running" ? block.progress : undefined;
|
|
140
|
+
const message = progress?.message ?? "";
|
|
141
|
+
const count = progress ? progressCount(progress) : "";
|
|
142
|
+
if (message || count) {
|
|
143
|
+
text.className = "hai-tstatus hai-tprogress";
|
|
144
|
+
const parts = [];
|
|
145
|
+
if (message) parts.push(h("span", "hai-tmessage", message));
|
|
146
|
+
if (message && count) parts.push(h("span", null, " · "));
|
|
147
|
+
if (count) parts.push(h("span", "hai-tcount", count));
|
|
148
|
+
text.replaceChildren(...parts);
|
|
149
|
+
} else {
|
|
150
|
+
text.className = "hai-tstatus";
|
|
151
|
+
text.textContent = (STATUS_TEXT[status] ?? ((ms) => `${ms} ms`))(block.ms);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!bar) return;
|
|
155
|
+
const total = progress?.total;
|
|
156
|
+
bar.hidden = !total;
|
|
157
|
+
if (!total) return;
|
|
158
|
+
const done = Math.min(progress.done ?? 0, total);
|
|
159
|
+
bar.setAttribute("aria-valuemax", String(total));
|
|
160
|
+
bar.setAttribute("aria-valuenow", String(done));
|
|
161
|
+
bar.setAttribute("aria-valuetext", [message, `${done} of ${total}`].filter(Boolean).join(" · "));
|
|
162
|
+
bar.style.setProperty("--hai-progress", `${(done / total) * 100}%`);
|
|
163
|
+
}
|
|
164
|
+
|
|
119
165
|
function statusMark(status) {
|
|
120
166
|
const mark = h("span", "hai-ticon");
|
|
121
167
|
if (status === "running") mark.append(h("span", "hai-spinner"));
|
|
@@ -283,13 +329,24 @@ function renderBlock(block, chat) {
|
|
|
283
329
|
const status = toolStatus(block, chat);
|
|
284
330
|
const el = h("details", `hai-block hai-tool hai-status-${status}`);
|
|
285
331
|
const summary = h("summary");
|
|
332
|
+
const parts = { status: h("span", "hai-tstatus"), bar: null };
|
|
286
333
|
summary.append(
|
|
287
334
|
statusMark(status),
|
|
288
335
|
h("span", "hai-tname", block.name),
|
|
289
336
|
h("span", "hai-targs", JSON.stringify(block.input)),
|
|
290
|
-
|
|
337
|
+
parts.status,
|
|
291
338
|
icon("chevron", "hai-icon hai-chevron"),
|
|
292
339
|
);
|
|
340
|
+
// a thin bar along the row's bottom edge, for progress with a total
|
|
341
|
+
if (status === "running") {
|
|
342
|
+
parts.bar = h("span", "hai-tbar");
|
|
343
|
+
parts.bar.setAttribute("role", "progressbar");
|
|
344
|
+
parts.bar.setAttribute("aria-label", `${block.name} progress`);
|
|
345
|
+
parts.bar.setAttribute("aria-valuemin", "0");
|
|
346
|
+
summary.append(parts.bar);
|
|
347
|
+
}
|
|
348
|
+
rowParts.set(el, parts);
|
|
349
|
+
showStatus(parts, block, status);
|
|
293
350
|
el.append(summary);
|
|
294
351
|
const detail = h("div", "hai-tool-detail");
|
|
295
352
|
detail.append(
|
|
@@ -319,6 +376,20 @@ function renderBlock(block, chat) {
|
|
|
319
376
|
return el;
|
|
320
377
|
}
|
|
321
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
|
+
|
|
322
393
|
default:
|
|
323
394
|
return h("div", "hai-block", JSON.stringify(block));
|
|
324
395
|
}
|