@haikit/client 0.17.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 +3 -0
- package/package.json +1 -1
- package/src/index.js +72 -15
- package/src/transcript.js +9 -8
package/README.md
CHANGED
|
@@ -89,6 +89,9 @@ component holds, such as its scroll, sort or a row the user picked. Without
|
|
|
89
89
|
it, the component is mounted again in place. Either way, `ctx.send` names the
|
|
90
90
|
surface's current handle, so a click after a revision reaches the new one.
|
|
91
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.
|
|
92
95
|
|
|
93
96
|
`chat.close()` is for good: anything open is aborted, every surface is
|
|
94
97
|
unmounted, and subscribers hear `{ type: "closed" }`. `mountChat` then removes
|
package/package.json
CHANGED
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,8 +242,10 @@ 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
250
|
else state.blocks.splice(afterNotices(at), 0, block);
|
|
234
251
|
break;
|
|
@@ -269,17 +286,37 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
269
286
|
|
|
270
287
|
case "ui_open": {
|
|
271
288
|
if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
|
|
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;
|
|
297
|
+
}
|
|
272
298
|
// A revision of a surface on screen takes over its record in place:
|
|
273
299
|
// its component, element and state stay, its handle moves on, and its
|
|
274
300
|
// props stay until the new ones arrive, so the transcript keeps the
|
|
275
301
|
// same element rather than mounting afresh.
|
|
276
|
-
|
|
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
|
+
}
|
|
277
313
|
let surface;
|
|
278
314
|
if (old) {
|
|
279
|
-
|
|
315
|
+
const was = old.handle;
|
|
316
|
+
state.surfaces.delete(was);
|
|
280
317
|
Object.assign(old, { handle: event.handle, component: event.component, version: event.version, revising: true });
|
|
281
318
|
state.surfaces.set(event.handle, (surface = old));
|
|
282
|
-
const block = state.blocks.find((b) => b.kind === "ui" && b.handle ===
|
|
319
|
+
const block = state.blocks.find((b) => b.kind === "ui" && b.handle === was);
|
|
283
320
|
if (block) block.handle = event.handle;
|
|
284
321
|
} else {
|
|
285
322
|
surface = {
|
|
@@ -296,10 +333,14 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
296
333
|
state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
|
|
297
334
|
}
|
|
298
335
|
// Notices come on a stream of their own, so one can arrive before the
|
|
299
|
-
// surface it names
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
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);
|
|
303
344
|
for (const notice of early) state.blocks.splice(state.blocks.indexOf(notice), 1);
|
|
304
345
|
const at = state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
|
|
305
346
|
if (at >= 0) state.blocks.splice(afterNotices(at), 0, ...early);
|
|
@@ -309,8 +350,8 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
309
350
|
// whichever is earlier. A revision brings its own, and the one it
|
|
310
351
|
// replaced stops counting.
|
|
311
352
|
if (typeof event.staleAfterMs === "number") {
|
|
312
|
-
const
|
|
313
|
-
surface.deadline =
|
|
353
|
+
const stored = typeof event.ageMs === "number" ? Math.min(from, Date.now() - event.ageMs) : from;
|
|
354
|
+
surface.deadline = stored + event.staleAfterMs;
|
|
314
355
|
surface.window = event.staleAfterMs;
|
|
315
356
|
} else {
|
|
316
357
|
surface.deadline = null;
|
|
@@ -394,7 +435,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
394
435
|
const opened = Date.now();
|
|
395
436
|
let delivered = false;
|
|
396
437
|
try {
|
|
397
|
-
const last = state.notices.at(-1)?.seq ?? 0;
|
|
438
|
+
const last = Math.max(resumeAfter, state.notices.at(-1)?.seq ?? 0);
|
|
398
439
|
const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
|
|
399
440
|
headers: last ? { "last-event-id": String(last) } : {},
|
|
400
441
|
signal,
|
|
@@ -413,13 +454,21 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
413
454
|
if (gen !== generation) return;
|
|
414
455
|
const frame = buffer.slice(0, i);
|
|
415
456
|
buffer = buffer.slice(i + 2);
|
|
416
|
-
const
|
|
457
|
+
const lines = frame.split("\n");
|
|
458
|
+
const line = lines.find((l) => l.startsWith("data: "));
|
|
417
459
|
if (line) {
|
|
418
460
|
const event = JSON.parse(line.slice(6));
|
|
419
461
|
followTurn(event);
|
|
420
|
-
|
|
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());
|
|
421
467
|
delivered = true;
|
|
422
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;
|
|
423
472
|
}
|
|
424
473
|
}
|
|
425
474
|
}
|
|
@@ -534,6 +583,12 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
534
583
|
return i;
|
|
535
584
|
}
|
|
536
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
|
+
|
|
537
592
|
/** The earliest deadline among the surfaces showing, and which window set it. */
|
|
538
593
|
function refreshDeadline() {
|
|
539
594
|
let soonest = null;
|
|
@@ -676,6 +731,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
676
731
|
aborter = new AbortController();
|
|
677
732
|
stopListening();
|
|
678
733
|
cutOff = false;
|
|
734
|
+
resumeAfter = 0;
|
|
679
735
|
clearTimeout(timer);
|
|
680
736
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
681
737
|
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
@@ -689,6 +745,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
689
745
|
notices: [],
|
|
690
746
|
});
|
|
691
747
|
state.surfaces.clear();
|
|
748
|
+
replacedBy.clear();
|
|
692
749
|
notify({ type: "reset" });
|
|
693
750
|
}
|
|
694
751
|
|
package/src/transcript.js
CHANGED
|
@@ -102,19 +102,20 @@ function toolStatus(block, chat) {
|
|
|
102
102
|
if (block.status !== "awaiting") return block.status;
|
|
103
103
|
const { blocks, surfaces, expired } = chat.state;
|
|
104
104
|
// Found once per row and kept: every answered question keeps its row
|
|
105
|
-
// `awaiting`, and this runs for each of them on every streamed event.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
109
110
|
(b) => b.kind === "ui" && b.toolId === block.id && surfaces.get(b.handle)?.mode === "elicit",
|
|
110
|
-
)
|
|
111
|
-
if (
|
|
111
|
+
);
|
|
112
|
+
if (question !== undefined) questions.set(block, question);
|
|
112
113
|
}
|
|
113
|
-
if (surfaces.get(handle)?.state === "frozen") return "resolved";
|
|
114
|
+
if (surfaces.get(question?.handle)?.state === "frozen") return "resolved";
|
|
114
115
|
return expired ? "expired" : "awaiting";
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
/** A tool row's block → the
|
|
118
|
+
/** A tool row's block → the block of the question it asked, once seen. */
|
|
118
119
|
const questions = new WeakMap();
|
|
119
120
|
|
|
120
121
|
const STATUS_TEXT = {
|