@haikit/client 0.1.0 → 0.3.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/package.json +1 -1
- package/src/index.js +66 -6
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -26,13 +26,65 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
26
26
|
|
|
27
27
|
const notify = (event) => listeners.forEach((fn) => fn(state, event));
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Requests open or waiting to go out — which is not the same as
|
|
31
|
+
* `state.status`.
|
|
32
|
+
*
|
|
33
|
+
* The server emits `awaiting` and `idle` from inside the turn, then releases
|
|
34
|
+
* its lease and saves after that frame has already reached us. Gating on
|
|
35
|
+
* status lets the next request go out during that tail, and the server
|
|
36
|
+
* answers 409. It matters because the composer is deliberately live while
|
|
37
|
+
* awaiting — "pick an option above, or type to override" — so an override
|
|
38
|
+
* there is the intended affordance, not a misuse.
|
|
39
|
+
*
|
|
40
|
+
* Requests are chained rather than refused: one never starts until the
|
|
41
|
+
* previous response has closed, so this client cannot collide with itself.
|
|
42
|
+
*/
|
|
43
|
+
let busy = 0;
|
|
44
|
+
let chain = Promise.resolve();
|
|
45
|
+
|
|
29
46
|
// ── transport: SSE over POST (EventSource cannot POST) ──────────────
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
function enqueue(path, body) {
|
|
48
|
+
busy++;
|
|
49
|
+
const run = chain.then(() => pump(path, body)).finally(() => {
|
|
50
|
+
busy--;
|
|
51
|
+
});
|
|
52
|
+
chain = run.catch(() => {});
|
|
53
|
+
return run;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const post = (path, body) =>
|
|
57
|
+
fetch(endpoint + path, {
|
|
32
58
|
method: "POST",
|
|
33
59
|
headers: { "content-type": "application/json" },
|
|
34
60
|
body: JSON.stringify({ conversationId: state.conversationId, ...body }),
|
|
35
61
|
});
|
|
62
|
+
|
|
63
|
+
async function pump(path, body) {
|
|
64
|
+
let res = await post(path, body);
|
|
65
|
+
|
|
66
|
+
// 409 means someone else holds this conversation's turn. Almost always that
|
|
67
|
+
// is the previous request's own tail, milliseconds from finishing — but it
|
|
68
|
+
// can also be a second tab, which no client-side guard can prevent. One
|
|
69
|
+
// retry covers the first and gives up honestly on the second.
|
|
70
|
+
if (res.status === 409) {
|
|
71
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
72
|
+
res = await post(path, body);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A non-SSE response carries no `data:` frames, so parsing it as a stream
|
|
76
|
+
// would fail silently and the UI would just sit there.
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const detail = await res.json().catch(() => ({}));
|
|
79
|
+
apply({
|
|
80
|
+
type: "error",
|
|
81
|
+
message:
|
|
82
|
+
detail.error ??
|
|
83
|
+
(res.status === 409 ? "another turn is already in flight" : `request failed (${res.status})`),
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
36
88
|
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
37
89
|
let buffer = "";
|
|
38
90
|
for (;;) {
|
|
@@ -143,14 +195,22 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
143
195
|
return surface.instance;
|
|
144
196
|
}
|
|
145
197
|
|
|
198
|
+
// A message is queued, never dropped. mountChat has already cleared the
|
|
199
|
+
// textarea by the time this runs, so refusing a send here does not decline
|
|
200
|
+
// it — it deletes what the user typed. That was true under the old
|
|
201
|
+
// `status === "streaming"` gate too, via the Enter key, which ignores the
|
|
202
|
+
// disabled send button.
|
|
146
203
|
async function send(text) {
|
|
147
|
-
if (!text.trim()
|
|
148
|
-
await
|
|
204
|
+
if (!text.trim()) return;
|
|
205
|
+
await enqueue("/chat", { message: text });
|
|
149
206
|
}
|
|
150
207
|
|
|
208
|
+
// A click carries nothing the user authored, and stacking them is worse than
|
|
209
|
+
// dropping them: a double-click on a picker row would resolve it and then
|
|
210
|
+
// queue a second resolution into "component is frozen".
|
|
151
211
|
async function interact(handle, action, value) {
|
|
152
|
-
if (
|
|
153
|
-
await
|
|
212
|
+
if (busy) return;
|
|
213
|
+
await enqueue("/interact", { handle, action, value });
|
|
154
214
|
}
|
|
155
215
|
|
|
156
216
|
return {
|