@haikit/client 0.16.0 → 0.18.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 +16 -4
- package/package.json +1 -1
- package/src/index.d.ts +12 -1
- package/src/index.js +149 -43
- package/src/transcript.js +12 -8
package/README.md
CHANGED
|
@@ -76,10 +76,22 @@ colour at all.
|
|
|
76
76
|
The default transcript keeps each block's element for as long as what it
|
|
77
77
|
shows is unchanged, so a surface mounts **once** and keeps its own state — a
|
|
78
78
|
filter, an expanded row, a React tree — while the conversation streams on.
|
|
79
|
-
`mount` may return `{ freeze, expire, unmount }`. `unmount` is called
|
|
80
|
-
surface goes away: `chat.reset()` starts a new conversation,
|
|
81
|
-
ends the chat, or the surface is mounted again. Release there
|
|
82
|
-
set up.
|
|
79
|
+
`mount` may return `{ freeze, expire, unmount, update }`. `unmount` is called
|
|
80
|
+
when the surface goes away: `chat.reset()` starts a new conversation,
|
|
81
|
+
`chat.close()` ends the chat, or the surface is mounted again. Release there
|
|
82
|
+
whatever `mount` set up.
|
|
83
|
+
|
|
84
|
+
**A tool can revise a surface in place** (`ctx.update`). The revision arrives
|
|
85
|
+
as a `ui_open` naming the handle it `replaces`. The client moves the surface's
|
|
86
|
+
record and transcript block to the new handle, keeping the same element, and
|
|
87
|
+
hands the new props to `update(props)`. Implement it to keep what the
|
|
88
|
+
component holds, such as its scroll, sort or a row the user picked. Without
|
|
89
|
+
it, the component is mounted again in place. Either way, `ctx.send` names the
|
|
90
|
+
surface's current handle, so a click after a revision reaches the new one.
|
|
91
|
+
Each surface keeps its own deadline, and a replaced surface's stops counting.
|
|
92
|
+
A revision app code makes from outside a turn (`hai.update`) arrives the same
|
|
93
|
+
way, on the events stream, and its deadline counts from when the server
|
|
94
|
+
stored it.
|
|
83
95
|
|
|
84
96
|
`chat.close()` is for good: anything open is aborted, every surface is
|
|
85
97
|
unmounted, and subscribers hear `{ type: "closed" }`. `mountChat` then removes
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -63,7 +63,8 @@ export type WireEvent = { type: string } & Record<string, any>;
|
|
|
63
63
|
* tool name, never a handler.
|
|
64
64
|
*/
|
|
65
65
|
export interface MountCtx {
|
|
66
|
-
handle:
|
|
66
|
+
/** The surface's handle as it is now: a revision in place (`update`) moves it on. */
|
|
67
|
+
readonly handle: string;
|
|
67
68
|
mode: SurfaceMode;
|
|
68
69
|
state: SurfaceState;
|
|
69
70
|
/** What the user picked, when the surface is mounted already frozen. */
|
|
@@ -89,6 +90,13 @@ export interface SurfaceInstance {
|
|
|
89
90
|
freeze?(selection?: unknown): void;
|
|
90
91
|
expire?(): void;
|
|
91
92
|
unmount?(): void;
|
|
93
|
+
/**
|
|
94
|
+
* The surface was revised in place (a tool's `ctx.update`): these are its
|
|
95
|
+
* new props. Implement it to keep what the component holds, such as its
|
|
96
|
+
* scroll, sort or expanded rows. Without it, the runtime mounts the
|
|
97
|
+
* component again in the same element, losing that.
|
|
98
|
+
*/
|
|
99
|
+
update?(props: any): void;
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
/** Your component. `props` is whatever the surface's `props` schema produces. */
|
|
@@ -135,6 +143,9 @@ export interface SurfaceRecord {
|
|
|
135
143
|
instance: SurfaceInstance | null;
|
|
136
144
|
/** The element it was last mounted into. */
|
|
137
145
|
element: HTMLElement | null;
|
|
146
|
+
/** When it goes out of date, in this browser's clock, or null if never. Its window, in ms. */
|
|
147
|
+
deadline?: number | null;
|
|
148
|
+
window?: number;
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
export interface ChatState {
|
package/src/index.js
CHANGED
|
@@ -36,6 +36,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
const notify = (event) => listeners.forEach((fn) => fn(state, event));
|
|
39
|
+
/**
|
|
40
|
+
* handle -> the handle that replaced it, for every revision this browser has
|
|
41
|
+
* heard of, including ones it only heard named; followed by `current`.
|
|
42
|
+
*/
|
|
43
|
+
const replacedBy = new Map();
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
46
|
* Requests open or waiting to go out — which is not the same as
|
|
@@ -95,6 +100,12 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
95
100
|
* a buffering proxy holds the stream back.
|
|
96
101
|
*/
|
|
97
102
|
let sentAt = 0;
|
|
103
|
+
/**
|
|
104
|
+
* The last notice the events stream finished sending, by its id: where a
|
|
105
|
+
* reconnect resumes. Update notices arrive as surface frames, not notices,
|
|
106
|
+
* so `state.notices` alone would miss them.
|
|
107
|
+
*/
|
|
108
|
+
let resumeAfter = 0;
|
|
98
109
|
|
|
99
110
|
// ── transport: SSE over POST (EventSource cannot POST) ──────────────
|
|
100
111
|
function enqueue(path, body) {
|
|
@@ -211,7 +222,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
211
222
|
}
|
|
212
223
|
}
|
|
213
224
|
|
|
214
|
-
|
|
225
|
+
/**
|
|
226
|
+
* `from`: when a surface in this event was rendered, at the latest, by this
|
|
227
|
+
* browser's clock, for one that no request of its own asked for.
|
|
228
|
+
*/
|
|
229
|
+
function apply(event, from = sentAt) {
|
|
215
230
|
switch (event.type) {
|
|
216
231
|
case "hello":
|
|
217
232
|
state.conversationId = event.conversationId;
|
|
@@ -227,14 +242,12 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
227
242
|
const { type, ...notice } = event;
|
|
228
243
|
state.notices.push(notice);
|
|
229
244
|
const block = { kind: "notice", id: `n:${event.seq}`, ...notice };
|
|
230
|
-
// beside its surface — after any notices already there — or at the end
|
|
231
|
-
|
|
245
|
+
// beside its surface — after any notices already there — or at the end;
|
|
246
|
+
// one naming an earlier handle of the surface goes beside it as it is now
|
|
247
|
+
const handle = event.handle === undefined ? undefined : current(event.handle);
|
|
248
|
+
const at = handle === undefined ? -1 : state.blocks.findIndex((b) => b.kind === "ui" && b.handle === handle);
|
|
232
249
|
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
|
-
}
|
|
250
|
+
else state.blocks.splice(afterNotices(at), 0, block);
|
|
238
251
|
break;
|
|
239
252
|
}
|
|
240
253
|
|
|
@@ -271,44 +284,94 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
271
284
|
break;
|
|
272
285
|
}
|
|
273
286
|
|
|
274
|
-
case "ui_open":
|
|
287
|
+
case "ui_open": {
|
|
275
288
|
if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
state
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
element: null,
|
|
285
|
-
});
|
|
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);
|
|
289
|
+
// Replaced already: a revision on the events stream overtook the
|
|
290
|
+
// request that showed the original, held back on its way here. The
|
|
291
|
+
// surface it became stands, and takes the original's tool row; the
|
|
292
|
+
// original isn't shown beside it, and its props go nowhere.
|
|
293
|
+
if (replacedBy.has(event.handle)) {
|
|
294
|
+
const now = state.blocks.find((b) => b.kind === "ui" && b.handle === current(event.handle));
|
|
295
|
+
if (now && !now.toolId) now.toolId = event.toolId;
|
|
296
|
+
break;
|
|
294
297
|
}
|
|
298
|
+
// A revision of a surface on screen takes over its record in place:
|
|
299
|
+
// its component, element and state stay, its handle moves on, and its
|
|
300
|
+
// props stay until the new ones arrive, so the transcript keeps the
|
|
301
|
+
// same element rather than mounting afresh.
|
|
302
|
+
// `replaces`, or, for a revision replayed after several, an earlier
|
|
303
|
+
// handle of the same surface: whichever this browser last saw
|
|
304
|
+
const old = [event.replaces, ...(event.alsoReplaces ?? [])]
|
|
305
|
+
.filter((h) => h !== undefined)
|
|
306
|
+
.map((h) => state.surfaces.get(h))
|
|
307
|
+
.find(Boolean);
|
|
308
|
+
// every earlier handle now leads here, seen or not, so a notice naming
|
|
309
|
+
// one finds this surface
|
|
310
|
+
for (const h of [event.replaces, ...(event.alsoReplaces ?? [])]) {
|
|
311
|
+
if (h !== undefined && h !== event.handle) replacedBy.set(h, event.handle);
|
|
312
|
+
}
|
|
313
|
+
let surface;
|
|
314
|
+
if (old) {
|
|
315
|
+
const was = old.handle;
|
|
316
|
+
state.surfaces.delete(was);
|
|
317
|
+
Object.assign(old, { handle: event.handle, component: event.component, version: event.version, revising: true });
|
|
318
|
+
state.surfaces.set(event.handle, (surface = old));
|
|
319
|
+
const block = state.blocks.find((b) => b.kind === "ui" && b.handle === was);
|
|
320
|
+
if (block) block.handle = event.handle;
|
|
321
|
+
} else {
|
|
322
|
+
surface = {
|
|
323
|
+
handle: event.handle,
|
|
324
|
+
component: event.component,
|
|
325
|
+
version: event.version,
|
|
326
|
+
mode: event.mode,
|
|
327
|
+
state: "live",
|
|
328
|
+
props: null,
|
|
329
|
+
instance: null,
|
|
330
|
+
element: null,
|
|
331
|
+
};
|
|
332
|
+
state.surfaces.set(event.handle, surface);
|
|
333
|
+
state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
|
|
334
|
+
}
|
|
335
|
+
// Notices come on a stream of their own, so one can arrive before the
|
|
336
|
+
// surface it names, or name a handle of it this browser hadn't heard
|
|
337
|
+
// of: it went to the end then, and moves under it now, among those
|
|
338
|
+
// already beside it in the order they were sent. Moved within the
|
|
339
|
+
// same array, which the transcript would otherwise take for a new
|
|
340
|
+
// conversation's.
|
|
341
|
+
const early = state.blocks
|
|
342
|
+
.filter((b) => b.kind === "notice" && b.handle !== undefined && current(b.handle) === event.handle)
|
|
343
|
+
.sort((x, y) => x.seq - y.seq);
|
|
344
|
+
for (const notice of early) state.blocks.splice(state.blocks.indexOf(notice), 1);
|
|
345
|
+
const at = state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
|
|
346
|
+
if (at >= 0) state.blocks.splice(afterNotices(at), 0, ...early);
|
|
347
|
+
else state.blocks.push(...early);
|
|
348
|
+
// Each surface keeps its own deadline: from when the request that
|
|
349
|
+
// rendered it was sent or, by its age, from when it was stored,
|
|
350
|
+
// whichever is earlier. A revision brings its own, and the one it
|
|
351
|
+
// replaced stops counting.
|
|
295
352
|
if (typeof event.staleAfterMs === "number") {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (state.expiresAt === null || at < state.expiresAt) {
|
|
302
|
-
state.expiresAt = at;
|
|
303
|
-
closingWindow = event.staleAfterMs;
|
|
304
|
-
schedule();
|
|
305
|
-
}
|
|
353
|
+
const stored = typeof event.ageMs === "number" ? Math.min(from, Date.now() - event.ageMs) : from;
|
|
354
|
+
surface.deadline = stored + event.staleAfterMs;
|
|
355
|
+
surface.window = event.staleAfterMs;
|
|
356
|
+
} else {
|
|
357
|
+
surface.deadline = null;
|
|
306
358
|
}
|
|
359
|
+
refreshDeadline();
|
|
307
360
|
break;
|
|
361
|
+
}
|
|
308
362
|
|
|
309
363
|
case "ui_props": {
|
|
310
364
|
const surface = state.surfaces.get(event.handle);
|
|
311
|
-
if (surface)
|
|
365
|
+
if (!surface) break;
|
|
366
|
+
surface.props = event.props;
|
|
367
|
+
if (surface.revising) {
|
|
368
|
+
// A revision's props reach the component already mounted: through
|
|
369
|
+
// its `update`, keeping its own state, or by mounting it again.
|
|
370
|
+
surface.revising = false;
|
|
371
|
+
const { instance, element } = surface;
|
|
372
|
+
if (instance && typeof instance.update === "function") attempt(() => instance.update(event.props));
|
|
373
|
+
else if (instance && element) mount(surface.handle, element);
|
|
374
|
+
}
|
|
312
375
|
break;
|
|
313
376
|
}
|
|
314
377
|
|
|
@@ -372,7 +435,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
372
435
|
const opened = Date.now();
|
|
373
436
|
let delivered = false;
|
|
374
437
|
try {
|
|
375
|
-
const last = state.notices.at(-1)?.seq ?? 0;
|
|
438
|
+
const last = Math.max(resumeAfter, state.notices.at(-1)?.seq ?? 0);
|
|
376
439
|
const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
|
|
377
440
|
headers: last ? { "last-event-id": String(last) } : {},
|
|
378
441
|
signal,
|
|
@@ -391,13 +454,21 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
391
454
|
if (gen !== generation) return;
|
|
392
455
|
const frame = buffer.slice(0, i);
|
|
393
456
|
buffer = buffer.slice(i + 2);
|
|
394
|
-
const
|
|
457
|
+
const lines = frame.split("\n");
|
|
458
|
+
const line = lines.find((l) => l.startsWith("data: "));
|
|
395
459
|
if (line) {
|
|
396
460
|
const event = JSON.parse(line.slice(6));
|
|
397
461
|
followTurn(event);
|
|
398
|
-
|
|
462
|
+
// A revision `hai.update` made comes outside any turn, even
|
|
463
|
+
// while one is running, so no request of this browser's
|
|
464
|
+
// dates it: only its age does. No tool call made it.
|
|
465
|
+
const outside = event.type === "ui_open" && event.toolId === "";
|
|
466
|
+
apply(event, remote && !outside ? sentAt : Date.now());
|
|
399
467
|
delivered = true;
|
|
400
468
|
}
|
|
469
|
+
// the notice this frame finished, revisions' included
|
|
470
|
+
const id = Number(lines.find((l) => l.startsWith("id: "))?.slice(4));
|
|
471
|
+
if (Number.isSafeInteger(id) && id > resumeAfter) resumeAfter = id;
|
|
401
472
|
}
|
|
402
473
|
}
|
|
403
474
|
}
|
|
@@ -500,6 +571,35 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
500
571
|
`${duration(closingWindow)}. Start a new conversation for current results.`,
|
|
501
572
|
});
|
|
502
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Where a notice for the surface at `at` goes: after the notices already
|
|
576
|
+
* beside it, which may name an earlier revision of it, so they keep the
|
|
577
|
+
* order they arrived in. A notice that names no surface sits at the end,
|
|
578
|
+
* not beside one, and isn't counted among them.
|
|
579
|
+
*/
|
|
580
|
+
function afterNotices(at) {
|
|
581
|
+
let i = at + 1;
|
|
582
|
+
while (state.blocks[i]?.kind === "notice" && state.blocks[i].handle !== undefined) i++;
|
|
583
|
+
return i;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The handle a surface has now, following every revision heard of since `handle`. */
|
|
587
|
+
function current(handle) {
|
|
588
|
+
while (replacedBy.has(handle)) handle = replacedBy.get(handle);
|
|
589
|
+
return handle;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** The earliest deadline among the surfaces showing, and which window set it. */
|
|
593
|
+
function refreshDeadline() {
|
|
594
|
+
let soonest = null;
|
|
595
|
+
for (const surface of state.surfaces.values()) {
|
|
596
|
+
if (surface.deadline != null && (soonest === null || surface.deadline < soonest.deadline)) soonest = surface;
|
|
597
|
+
}
|
|
598
|
+
state.expiresAt = soonest?.deadline ?? null;
|
|
599
|
+
if (soonest) closingWindow = soonest.window;
|
|
600
|
+
schedule();
|
|
601
|
+
}
|
|
602
|
+
|
|
503
603
|
// One timer, for the earliest deadline. setTimeout fires at once for delays
|
|
504
604
|
// past about 24.8 days, so a long wait is taken in steps.
|
|
505
605
|
function schedule() {
|
|
@@ -546,13 +646,17 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
546
646
|
element.replaceChildren();
|
|
547
647
|
surface.element = element;
|
|
548
648
|
surface.instance = definition.mount(element, surface.props, {
|
|
549
|
-
|
|
649
|
+
// the surface as it is now, as `send` below: a revision moves it on
|
|
650
|
+
get handle() {
|
|
651
|
+
return surface.handle;
|
|
652
|
+
},
|
|
550
653
|
mode: surface.mode,
|
|
551
654
|
state: surface.state,
|
|
552
655
|
selection: surface.selection,
|
|
553
656
|
// The ONLY channel a component has to the server. It passes an action
|
|
554
657
|
// name declared in the contract — never a tool, never a handler.
|
|
555
|
-
|
|
658
|
+
// and names the surface as it is now: a revision moves it on.
|
|
659
|
+
send: (action, value) => interact(surface.handle, action, value),
|
|
556
660
|
});
|
|
557
661
|
if (state.expired) seal(surface);
|
|
558
662
|
return surface.instance;
|
|
@@ -627,6 +731,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
627
731
|
aborter = new AbortController();
|
|
628
732
|
stopListening();
|
|
629
733
|
cutOff = false;
|
|
734
|
+
resumeAfter = 0;
|
|
630
735
|
clearTimeout(timer);
|
|
631
736
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
632
737
|
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
@@ -640,6 +745,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
640
745
|
notices: [],
|
|
641
746
|
});
|
|
642
747
|
state.surfaces.clear();
|
|
748
|
+
replacedBy.clear();
|
|
643
749
|
notify({ type: "reset" });
|
|
644
750
|
}
|
|
645
751
|
|
package/src/transcript.js
CHANGED
|
@@ -46,6 +46,9 @@ 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.kind === "ui" && el.dataset.handle !== block.handle) {
|
|
50
|
+
// a revision moved this surface to a new handle; its element stays
|
|
51
|
+
el.dataset.handle = block.handle;
|
|
49
52
|
} else if (block.progress !== before.progress) {
|
|
50
53
|
// Progress changes the row in place. Rebuilt for every frame, a row a
|
|
51
54
|
// keyboard user is on would take their focus up to ten times a second.
|
|
@@ -99,19 +102,20 @@ function toolStatus(block, chat) {
|
|
|
99
102
|
if (block.status !== "awaiting") return block.status;
|
|
100
103
|
const { blocks, surfaces, expired } = chat.state;
|
|
101
104
|
// Found once per row and kept: every answered question keeps its row
|
|
102
|
-
// `awaiting`, and this runs for each of them on every streamed event.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
// `awaiting`, and this runs for each of them on every streamed event. The
|
|
106
|
+
// surface's block is kept, not its handle, which a revision moves on.
|
|
107
|
+
let question = questions.get(block);
|
|
108
|
+
if (question === undefined) {
|
|
109
|
+
question = blocks.find(
|
|
106
110
|
(b) => b.kind === "ui" && b.toolId === block.id && surfaces.get(b.handle)?.mode === "elicit",
|
|
107
|
-
)
|
|
108
|
-
if (
|
|
111
|
+
);
|
|
112
|
+
if (question !== undefined) questions.set(block, question);
|
|
109
113
|
}
|
|
110
|
-
if (surfaces.get(handle)?.state === "frozen") return "resolved";
|
|
114
|
+
if (surfaces.get(question?.handle)?.state === "frozen") return "resolved";
|
|
111
115
|
return expired ? "expired" : "awaiting";
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
/** A tool row's block → the
|
|
118
|
+
/** A tool row's block → the block of the question it asked, once seen. */
|
|
115
119
|
const questions = new WeakMap();
|
|
116
120
|
|
|
117
121
|
const STATUS_TEXT = {
|