@haikit/client 0.13.0 → 0.15.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 +7 -0
- package/package.json +1 -1
- package/src/index.js +83 -4
package/README.md
CHANGED
|
@@ -111,6 +111,13 @@ mountChat({
|
|
|
111
111
|
});
|
|
112
112
|
```
|
|
113
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
|
+
|
|
114
121
|
A notice's component gets **no `send`**: nothing in it can reach the server. A
|
|
115
122
|
name the registry doesn't list renders an error card. Like a surface, `mount`
|
|
116
123
|
may return `{ unmount }`. With `createChat`, mount one yourself with
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -72,6 +72,19 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
72
72
|
/** The events stream's conversation while it is open, and how to stop it. */
|
|
73
73
|
let listening = null;
|
|
74
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;
|
|
75
88
|
/** seq → the mounted notice's instance and element */
|
|
76
89
|
const noticeMounts = new Map();
|
|
77
90
|
|
|
@@ -117,6 +130,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
117
130
|
// The deadline is checked again here, not only when queued: a long turn
|
|
118
131
|
// ahead of this request can run past it while it waits.
|
|
119
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
|
+
}
|
|
120
138
|
sentAt = Date.now();
|
|
121
139
|
let res = await post(path, body, signal);
|
|
122
140
|
|
|
@@ -128,8 +146,26 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
128
146
|
await new Promise((r) => setTimeout(r, 150));
|
|
129
147
|
// the deadline can pass during the wait, and reset() or close() come
|
|
130
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
|
+
}
|
|
131
155
|
res = await post(path, body, signal); // rejects at once if reset() aborted it meanwhile
|
|
132
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;
|
|
133
169
|
// Anything but a second 409 means the server took the request up itself —
|
|
134
170
|
// even an error — unless the stream says `expired` below. A 409 means it
|
|
135
171
|
// turned the message away unread.
|
|
@@ -202,9 +238,15 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
202
238
|
break;
|
|
203
239
|
}
|
|
204
240
|
|
|
205
|
-
|
|
206
|
-
|
|
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 });
|
|
207
248
|
break;
|
|
249
|
+
}
|
|
208
250
|
|
|
209
251
|
case "text_delta": {
|
|
210
252
|
const block = state.blocks.find((b) => b.id === event.id);
|
|
@@ -230,6 +272,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
230
272
|
}
|
|
231
273
|
|
|
232
274
|
case "ui_open":
|
|
275
|
+
if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
|
|
233
276
|
state.surfaces.set(event.handle, {
|
|
234
277
|
handle: event.handle,
|
|
235
278
|
component: event.component,
|
|
@@ -346,15 +389,21 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
346
389
|
buffer = buffer.slice(i + 2);
|
|
347
390
|
const line = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
348
391
|
if (line) {
|
|
349
|
-
|
|
392
|
+
const event = JSON.parse(line.slice(6));
|
|
393
|
+
followTurn(event);
|
|
394
|
+
apply(event);
|
|
350
395
|
delivered = true;
|
|
351
396
|
}
|
|
352
397
|
}
|
|
353
398
|
}
|
|
354
399
|
}
|
|
355
400
|
} catch {
|
|
356
|
-
if (signal.aborted) return;
|
|
401
|
+
if (signal.aborted) return finishRemote();
|
|
357
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();
|
|
358
407
|
if (delivered || Date.now() - opened >= HEALTHY_MS) wait = 1_000;
|
|
359
408
|
// Dropped, refused, or the network went away: try again, backing off.
|
|
360
409
|
// reset() or close() ends the wait at once, timer and all.
|
|
@@ -371,7 +420,29 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
371
420
|
}
|
|
372
421
|
}
|
|
373
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
|
+
|
|
374
444
|
function stopListening() {
|
|
445
|
+
finishRemote();
|
|
375
446
|
listenAborter?.abort();
|
|
376
447
|
listenAborter = null;
|
|
377
448
|
listening = null;
|
|
@@ -551,6 +622,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
551
622
|
aborter.abort();
|
|
552
623
|
aborter = new AbortController();
|
|
553
624
|
stopListening();
|
|
625
|
+
cutOff = false;
|
|
554
626
|
clearTimeout(timer);
|
|
555
627
|
for (const surface of state.surfaces.values()) unmount(surface);
|
|
556
628
|
for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
|
|
@@ -607,6 +679,13 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
607
679
|
};
|
|
608
680
|
}
|
|
609
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
|
+
|
|
610
689
|
/** An events connection that stays up this long was healthy, whatever it delivered. */
|
|
611
690
|
const HEALTHY_MS = 30_000;
|
|
612
691
|
|