@haikit/client 0.3.0 → 0.4.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 +9 -0
- package/package.json +1 -1
- package/src/app.js +15 -3
- package/src/hai.css +25 -0
- package/src/index.d.ts +38 -3
- package/src/index.js +187 -19
- package/src/transcript.js +9 -0
package/README.md
CHANGED
|
@@ -18,6 +18,15 @@ not style the inside of your surfaces; your components emit their own class
|
|
|
18
18
|
names and your stylesheet owns them. Every colour is a `--hai-*` custom
|
|
19
19
|
property, so override what you like.
|
|
20
20
|
|
|
21
|
+
## Out-of-date conversations
|
|
22
|
+
|
|
23
|
+
The server closes a conversation once any surface passes its freshness window.
|
|
24
|
+
The client knows each window too, so a tab left open closes on time instead of
|
|
25
|
+
looking live until a click is refused: `state.expired` holds the notice,
|
|
26
|
+
`send` and `interact` stop sending, and each surface's element is made `inert`
|
|
27
|
+
with `data-expired` set. `chat.reset()` starts a new conversation; the default
|
|
28
|
+
transcript renders the notice with a button that calls it.
|
|
29
|
+
|
|
21
30
|
## Serving it
|
|
22
31
|
|
|
23
32
|
This package ships source, not a bundle. With a bundler, import normally. With
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -83,8 +83,17 @@ export function mountChat({
|
|
|
83
83
|
const send = () => {
|
|
84
84
|
const text = input.value.trim();
|
|
85
85
|
if (!text) return;
|
|
86
|
+
const sending = chat.send(text);
|
|
87
|
+
// send() refuses an out-of-date conversation before its first await, so
|
|
88
|
+
// this already knows: keep the text for the new conversation instead
|
|
89
|
+
if (chat.state.expired) return;
|
|
86
90
|
input.value = "";
|
|
87
|
-
|
|
91
|
+
// A message queued behind a long turn can still be turned away later, if
|
|
92
|
+
// the conversation goes out of date before its turn comes. Give the text
|
|
93
|
+
// back then, unless something new has been typed since.
|
|
94
|
+
sending.then((sent) => {
|
|
95
|
+
if (!sent && !input.value) input.value = text;
|
|
96
|
+
});
|
|
88
97
|
};
|
|
89
98
|
|
|
90
99
|
function renderEmpty() {
|
|
@@ -108,14 +117,17 @@ export function mountChat({
|
|
|
108
117
|
else renderTranscript(transcript, chat);
|
|
109
118
|
|
|
110
119
|
modelEl.textContent = state.model;
|
|
111
|
-
|
|
120
|
+
input.disabled = state.expired !== null;
|
|
121
|
+
sendBtn.disabled = state.status === "streaming" || state.expired !== null;
|
|
112
122
|
statusEl.dataset.status = state.status;
|
|
113
123
|
statusEl.textContent =
|
|
114
124
|
state.status === "streaming" ? "thinking…"
|
|
115
125
|
: state.status === "awaiting" ? "awaiting your selection in the component above"
|
|
116
126
|
: "";
|
|
117
127
|
input.placeholder =
|
|
118
|
-
state.
|
|
128
|
+
state.expired ? "start a new conversation to continue"
|
|
129
|
+
: state.status === "awaiting" ? "pick an option above — or type to override"
|
|
130
|
+
: placeholder;
|
|
119
131
|
|
|
120
132
|
if (inspectorBody) renderInspector(inspectorBody, state.context);
|
|
121
133
|
});
|
package/src/hai.css
CHANGED
|
@@ -141,6 +141,30 @@
|
|
|
141
141
|
.hai-skeleton, .hai-surface-error { padding: 14px; font: 12px var(--hai-mono); color: var(--hai-dim); }
|
|
142
142
|
.hai-surface-error { color: var(--hai-bad); }
|
|
143
143
|
|
|
144
|
+
/* out of date — the runtime has made the surface inert; this only shows it */
|
|
145
|
+
.hai-surface[data-expired] { opacity: .45; filter: grayscale(.7); }
|
|
146
|
+
.hai-expired {
|
|
147
|
+
display: flex;
|
|
148
|
+
flex-wrap: wrap;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 10px 14px;
|
|
151
|
+
padding: 10px 12px;
|
|
152
|
+
border: 1px solid var(--hai-warn);
|
|
153
|
+
border-radius: var(--hai-radius);
|
|
154
|
+
color: var(--hai-warn);
|
|
155
|
+
}
|
|
156
|
+
.hai-expired .hai-body { flex: 1 1 30ch; }
|
|
157
|
+
.hai-new {
|
|
158
|
+
background: var(--hai-warn);
|
|
159
|
+
color: var(--hai-bg);
|
|
160
|
+
border: none;
|
|
161
|
+
border-radius: 8px;
|
|
162
|
+
padding: 6px 14px;
|
|
163
|
+
font: inherit;
|
|
164
|
+
font-weight: 600;
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
}
|
|
167
|
+
|
|
144
168
|
/* ── status + composer ──────────────────────────────────────────────── */
|
|
145
169
|
.hai-status { padding: 0 20px 4px; font-size: 12px; min-height: 18px; color: var(--hai-dim); }
|
|
146
170
|
.hai-status[data-status="awaiting"] { color: var(--hai-warn); }
|
|
@@ -158,6 +182,7 @@
|
|
|
158
182
|
max-height: 180px;
|
|
159
183
|
}
|
|
160
184
|
.hai-input:focus { outline: none; border-color: var(--hai-accent); }
|
|
185
|
+
.hai-input:disabled { opacity: .5; }
|
|
161
186
|
.hai-send {
|
|
162
187
|
background: var(--hai-accent);
|
|
163
188
|
color: var(--hai-bg);
|
package/src/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type ConversationStatus = "idle" | "streaming" | "awaiting";
|
|
|
12
12
|
export type SurfaceMode = "display" | "elicit";
|
|
13
13
|
export type SurfaceState = "live" | "frozen";
|
|
14
14
|
|
|
15
|
-
/** Blocks the server emits, plus the
|
|
15
|
+
/** Blocks the server emits, plus the three the client synthesises locally. */
|
|
16
16
|
export type Block =
|
|
17
17
|
| { kind: "user"; id: string; text: string }
|
|
18
18
|
| { kind: "assistant"; id: string; text: string }
|
|
@@ -21,7 +21,9 @@ export type Block =
|
|
|
21
21
|
/** synthesised from `ui_open` — the slot a surface mounts into */
|
|
22
22
|
| { kind: "ui"; id: string; handle: string; toolId: string }
|
|
23
23
|
/** synthesised from `error` */
|
|
24
|
-
| { kind: "error"; id: string; message: string }
|
|
24
|
+
| { kind: "error"; id: string; message: string }
|
|
25
|
+
/** synthesised from `expired` — the conversation is closed; offer `reset()` */
|
|
26
|
+
| { kind: "expired"; id: string; message: string };
|
|
25
27
|
|
|
26
28
|
/** The SSE frame shape. Narrow on `type` to get the specific payload. */
|
|
27
29
|
export type WireEvent = { type: string } & Record<string, any>;
|
|
@@ -42,9 +44,14 @@ export interface MountCtx {
|
|
|
42
44
|
* Returned by `mount`. `freeze` is called when the server marks the surface
|
|
43
45
|
* frozen — a resolved elicit turn cannot be replayed, and the component should
|
|
44
46
|
* reflect that.
|
|
47
|
+
*
|
|
48
|
+
* `expire` is called when the conversation goes out of date. By then the
|
|
49
|
+
* runtime has already made the element `inert` and set `data-expired` on it,
|
|
50
|
+
* so nothing inside can reach the server; this hook is only for looks.
|
|
45
51
|
*/
|
|
46
52
|
export interface SurfaceInstance {
|
|
47
53
|
freeze?(selection?: unknown): void;
|
|
54
|
+
expire?(): void;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
/** Your component. `props` is whatever the surface's `props` schema produces. */
|
|
@@ -66,6 +73,8 @@ export interface SurfaceRecord {
|
|
|
66
73
|
state: SurfaceState;
|
|
67
74
|
props: unknown | null;
|
|
68
75
|
instance: SurfaceInstance | null;
|
|
76
|
+
/** The element it was last mounted into. */
|
|
77
|
+
element: HTMLElement | null;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
export interface ChatState {
|
|
@@ -76,15 +85,41 @@ export interface ChatState {
|
|
|
76
85
|
/** handle → surface record */
|
|
77
86
|
surfaces: Map<string, SurfaceRecord>;
|
|
78
87
|
context: { messages: any[]; modelTokens: number; uiTokens: number };
|
|
88
|
+
/**
|
|
89
|
+
* When this conversation goes out of date, in this browser's clock (epoch
|
|
90
|
+
* ms): the earliest freshness window among its surfaces, each counted from
|
|
91
|
+
* when the request that rendered it was sent — so never later than the
|
|
92
|
+
* server's own deadline. Null while nothing it shows can go stale.
|
|
93
|
+
*/
|
|
94
|
+
expiresAt: number | null;
|
|
95
|
+
/**
|
|
96
|
+
* The notice to show once the conversation is out of date, else null. From
|
|
97
|
+
* then on `send` and `interact` do nothing — the server would refuse them —
|
|
98
|
+
* until `reset()` starts a new conversation.
|
|
99
|
+
*/
|
|
100
|
+
expired: string | null;
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
export interface Chat {
|
|
82
104
|
/** Mutable — read it in a subscriber, do not hold references across events. */
|
|
83
105
|
state: ChatState;
|
|
84
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Queue a message. Resolves `true` once the server has taken it, `false` if
|
|
108
|
+
* it never did: the conversation went out of date first (at the call, or
|
|
109
|
+
* while it waited behind a long turn), `reset()` started a new conversation
|
|
110
|
+
* before its turn, or the server was still busy after one retry. A UI that
|
|
111
|
+
* cleared its input can then put the text back.
|
|
112
|
+
*/
|
|
113
|
+
send(text: string): Promise<boolean>;
|
|
85
114
|
interact(handle: string, action: string, value: unknown): Promise<void>;
|
|
86
115
|
/** Mounts the surface for `handle` into `element`. Null if props have not arrived. */
|
|
87
116
|
mount(handle: string, element: HTMLElement): SurfaceInstance | null;
|
|
117
|
+
/**
|
|
118
|
+
* Start a new conversation on the next send. Anything queued for the old one
|
|
119
|
+
* is never sent, and a request still open is aborted — the new conversation
|
|
120
|
+
* never waits behind it. Subscribers are notified with `{ type: "reset" }`.
|
|
121
|
+
*/
|
|
122
|
+
reset(): void;
|
|
88
123
|
/** Returns an unsubscribe function. */
|
|
89
124
|
subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
|
|
90
125
|
}
|
package/src/index.js
CHANGED
|
@@ -19,9 +19,18 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
19
19
|
model: "",
|
|
20
20
|
status: "idle",
|
|
21
21
|
blocks: [],
|
|
22
|
-
/** handle -> { component, version, mode, props, state, instance } */
|
|
22
|
+
/** handle -> { component, version, mode, props, state, instance, element } */
|
|
23
23
|
surfaces: new Map(),
|
|
24
24
|
context: { messages: [], modelTokens: 0, uiTokens: 0 },
|
|
25
|
+
/**
|
|
26
|
+
* When this conversation goes out of date, by this browser's clock: the
|
|
27
|
+
* earliest freshness window among its surfaces, each counted from when the
|
|
28
|
+
* request that rendered it was sent — so never later than the server's own
|
|
29
|
+
* deadline. Null while nothing it shows can go stale.
|
|
30
|
+
*/
|
|
31
|
+
expiresAt: null,
|
|
32
|
+
/** The notice, once the conversation is out of date. Final until `reset()`. */
|
|
33
|
+
expired: null,
|
|
25
34
|
};
|
|
26
35
|
|
|
27
36
|
const notify = (event) => listeners.forEach((fn) => fn(state, event));
|
|
@@ -43,25 +52,60 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
43
52
|
let busy = 0;
|
|
44
53
|
let chain = Promise.resolve();
|
|
45
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Bumped by `reset()`. A request belongs to the conversation that was current
|
|
57
|
+
* when it was made, and carries that conversation's abort signal: reset()
|
|
58
|
+
* aborts it, so one still queued is refused by fetch before anything is sent,
|
|
59
|
+
* and one already open stops. Waiting instead would queue the new
|
|
60
|
+
* conversation's first request behind a stream that can stay open for a whole
|
|
61
|
+
* model turn. Events already read are dropped too, so a stray `hello` cannot
|
|
62
|
+
* pull this client back into the conversation it just left.
|
|
63
|
+
*/
|
|
64
|
+
let generation = 0;
|
|
65
|
+
let aborter = new AbortController();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* When the request now streaming was sent. The server stamps every surface
|
|
69
|
+
* it renders after that request arrived, so a freshness deadline counted from
|
|
70
|
+
* here is never later than the server's — however long the model takes, or
|
|
71
|
+
* a buffering proxy holds the stream back.
|
|
72
|
+
*/
|
|
73
|
+
let sentAt = 0;
|
|
74
|
+
|
|
46
75
|
// ── transport: SSE over POST (EventSource cannot POST) ──────────────
|
|
47
76
|
function enqueue(path, body) {
|
|
77
|
+
const gen = generation;
|
|
78
|
+
const { signal } = aborter;
|
|
79
|
+
// set by pump once the server has answered this request itself
|
|
80
|
+
const outcome = { sent: false };
|
|
48
81
|
busy++;
|
|
49
|
-
const run = chain
|
|
50
|
-
|
|
51
|
-
|
|
82
|
+
const run = chain
|
|
83
|
+
.then(() => pump(path, body, gen, signal, outcome))
|
|
84
|
+
.catch((err) => {
|
|
85
|
+
// aborted by reset(): it belonged to a conversation the user has left
|
|
86
|
+
if (gen === generation) throw err;
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
89
|
+
busy--;
|
|
90
|
+
});
|
|
52
91
|
chain = run.catch(() => {});
|
|
53
|
-
return run;
|
|
92
|
+
return run.then(() => outcome.sent);
|
|
54
93
|
}
|
|
55
94
|
|
|
56
|
-
const post = (path, body) =>
|
|
95
|
+
const post = (path, body, signal) =>
|
|
57
96
|
fetch(endpoint + path, {
|
|
58
97
|
method: "POST",
|
|
59
98
|
headers: { "content-type": "application/json" },
|
|
60
99
|
body: JSON.stringify({ conversationId: state.conversationId, ...body }),
|
|
100
|
+
signal,
|
|
61
101
|
});
|
|
62
102
|
|
|
63
|
-
async function pump(path, body) {
|
|
64
|
-
|
|
103
|
+
async function pump(path, body, gen, signal, outcome) {
|
|
104
|
+
// Checked again here, not only when queued: a long turn ahead of this
|
|
105
|
+
// request can run past the deadline while it waits.
|
|
106
|
+
if (closed()) return;
|
|
107
|
+
sentAt = Date.now();
|
|
108
|
+
let res = await post(path, body, signal);
|
|
65
109
|
|
|
66
110
|
// 409 means someone else holds this conversation's turn. Almost always that
|
|
67
111
|
// is the previous request's own tail, milliseconds from finishing — but it
|
|
@@ -69,13 +113,21 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
69
113
|
// retry covers the first and gives up honestly on the second.
|
|
70
114
|
if (res.status === 409) {
|
|
71
115
|
await new Promise((r) => setTimeout(r, 150));
|
|
72
|
-
|
|
116
|
+
// the deadline can pass during the wait, like any other queued request
|
|
117
|
+
if (closed()) return;
|
|
118
|
+
res = await post(path, body, signal); // rejects at once if reset() aborted it meanwhile
|
|
73
119
|
}
|
|
120
|
+
// Anything but a second 409 means the server took the request up itself —
|
|
121
|
+
// even an error — unless the stream says `expired` below. A 409 means it
|
|
122
|
+
// turned the message away unread.
|
|
123
|
+
outcome.sent = res.status !== 409;
|
|
74
124
|
|
|
75
125
|
// A non-SSE response carries no `data:` frames, so parsing it as a stream
|
|
76
126
|
// would fail silently and the UI would just sit there.
|
|
77
127
|
if (!res.ok) {
|
|
78
128
|
const detail = await res.json().catch(() => ({}));
|
|
129
|
+
// an abort during that read is swallowed by the catch, so check here
|
|
130
|
+
if (gen !== generation) return;
|
|
79
131
|
apply({
|
|
80
132
|
type: "error",
|
|
81
133
|
message:
|
|
@@ -93,10 +145,19 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
93
145
|
buffer += value;
|
|
94
146
|
let i;
|
|
95
147
|
while ((i = buffer.indexOf("\n\n")) >= 0) {
|
|
148
|
+
// Per frame, not per chunk: a subscriber may call reset() from inside
|
|
149
|
+
// apply(), and the rest of this chunk belongs to the old conversation.
|
|
150
|
+
// The abort stops later reads; it cannot recall what is already here.
|
|
151
|
+
if (gen !== generation) return;
|
|
96
152
|
const frame = buffer.slice(0, i);
|
|
97
153
|
buffer = buffer.slice(i + 2);
|
|
98
154
|
const line = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
99
|
-
if (line)
|
|
155
|
+
if (!line) continue;
|
|
156
|
+
const event = JSON.parse(line.slice(6));
|
|
157
|
+
// refused inside a 200: the server recorded nothing, so it did not
|
|
158
|
+
// take the message, whatever the status said
|
|
159
|
+
if (event.type === "expired") outcome.sent = false;
|
|
160
|
+
apply(event);
|
|
100
161
|
}
|
|
101
162
|
}
|
|
102
163
|
}
|
|
@@ -133,8 +194,17 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
133
194
|
state: "live",
|
|
134
195
|
props: null,
|
|
135
196
|
instance: null,
|
|
197
|
+
element: null,
|
|
136
198
|
});
|
|
137
199
|
state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
|
|
200
|
+
if (typeof event.staleAfterMs === "number") {
|
|
201
|
+
const at = sentAt + event.staleAfterMs;
|
|
202
|
+
if (state.expiresAt === null || at < state.expiresAt) {
|
|
203
|
+
state.expiresAt = at;
|
|
204
|
+
closingWindow = event.staleAfterMs;
|
|
205
|
+
schedule();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
138
208
|
break;
|
|
139
209
|
|
|
140
210
|
case "ui_props": {
|
|
@@ -164,10 +234,62 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
164
234
|
state.blocks.push({ kind: "error", id: `e${state.blocks.length}`, message: event.message });
|
|
165
235
|
state.status = "idle";
|
|
166
236
|
break;
|
|
237
|
+
|
|
238
|
+
// The local timer and the server can both report this; once is enough.
|
|
239
|
+
case "expired":
|
|
240
|
+
if (state.expired) return;
|
|
241
|
+
state.expired = event.message;
|
|
242
|
+
state.status = "idle";
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
state.blocks.push({ kind: "expired", id: `x${state.blocks.length}`, message: event.message });
|
|
245
|
+
for (const surface of state.surfaces.values()) seal(surface);
|
|
246
|
+
break;
|
|
167
247
|
}
|
|
168
248
|
notify(event);
|
|
169
249
|
}
|
|
170
250
|
|
|
251
|
+
// ── expiry ─────────────────────────────────────────────────────────
|
|
252
|
+
// The server refuses every request on an out-of-date conversation. Knowing
|
|
253
|
+
// the deadline here too means a tab left open greys out on time, rather than
|
|
254
|
+
// looking live until a click comes back refused.
|
|
255
|
+
|
|
256
|
+
let timer = null;
|
|
257
|
+
let closingWindow = 0;
|
|
258
|
+
|
|
259
|
+
const outOfDate = () => state.expiresAt !== null && Date.now() >= state.expiresAt;
|
|
260
|
+
|
|
261
|
+
const expireHere = () =>
|
|
262
|
+
apply({
|
|
263
|
+
type: "expired",
|
|
264
|
+
message:
|
|
265
|
+
`This conversation is out of date — results shown here were only valid for ` +
|
|
266
|
+
`${duration(closingWindow)}. Start a new conversation for current results.`,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// One timer, for the earliest deadline. setTimeout fires at once for delays
|
|
270
|
+
// past about 24.8 days, so a long wait is taken in steps.
|
|
271
|
+
function schedule() {
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
if (state.expiresAt === null || state.expired) return;
|
|
274
|
+
const wait = Math.min(Math.max(state.expiresAt - Date.now(), 0), 2_147_483_647);
|
|
275
|
+
timer = setTimeout(() => (outOfDate() ? expireHere() : schedule()), wait);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Checked against the clock as well as the flag: a laptop that slept
|
|
279
|
+
* through the deadline may not have run the timer yet. */
|
|
280
|
+
function closed() {
|
|
281
|
+
if (!state.expired && outOfDate()) expireHere();
|
|
282
|
+
return state.expired !== null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Nothing inside an out-of-date surface may reach the server again. */
|
|
286
|
+
function seal(surface) {
|
|
287
|
+
if (!surface.element) return;
|
|
288
|
+
surface.element.inert = true;
|
|
289
|
+
surface.element.dataset.expired = "";
|
|
290
|
+
surface.instance?.expire?.();
|
|
291
|
+
}
|
|
292
|
+
|
|
171
293
|
/**
|
|
172
294
|
* Mount a surface into an element. Strict allowlist: an unregistered
|
|
173
295
|
* component renders an error card, never an improvised UI.
|
|
@@ -184,6 +306,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
184
306
|
}
|
|
185
307
|
|
|
186
308
|
element.replaceChildren();
|
|
309
|
+
surface.element = element;
|
|
187
310
|
surface.instance = definition.mount(element, surface.props, {
|
|
188
311
|
handle,
|
|
189
312
|
mode: surface.mode,
|
|
@@ -192,35 +315,80 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
192
315
|
// name declared in the contract — never a tool, never a handler.
|
|
193
316
|
send: (action, value) => interact(handle, action, value),
|
|
194
317
|
});
|
|
318
|
+
if (state.expired) seal(surface);
|
|
195
319
|
return surface.instance;
|
|
196
320
|
}
|
|
197
321
|
|
|
198
|
-
// A message is queued, never
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
322
|
+
// A message is queued, never refused for being busy. It resolves to whether
|
|
323
|
+
// the server took it: false if the conversation went out of date first —
|
|
324
|
+
// at the call, or while it waited behind a long turn — if reset() started
|
|
325
|
+
// a new one before its turn, or if the server was still busy after the
|
|
326
|
+
// retry. A caller that cleared its input can then give the text back. The
|
|
327
|
+
// out-of-date refusal at the call happens before the first await, so such a
|
|
328
|
+
// caller can also check `state.expired` straight after calling and never
|
|
329
|
+
// clear the input at all.
|
|
203
330
|
async function send(text) {
|
|
204
|
-
if (!text.trim()) return;
|
|
205
|
-
|
|
331
|
+
if (!text.trim() || closed()) return false;
|
|
332
|
+
return enqueue("/chat", { message: text });
|
|
206
333
|
}
|
|
207
334
|
|
|
208
335
|
// A click carries nothing the user authored, and stacking them is worse than
|
|
209
336
|
// dropping them: a double-click on a picker row would resolve it and then
|
|
210
|
-
// queue a second resolution into "component is frozen".
|
|
337
|
+
// queue a second resolution into "component is frozen". The deadline is
|
|
338
|
+
// checked first, so a click that cannot be sent still closes a conversation
|
|
339
|
+
// whose timer slept through its deadline.
|
|
211
340
|
async function interact(handle, action, value) {
|
|
212
|
-
if (busy) return;
|
|
341
|
+
if (closed() || busy) return;
|
|
213
342
|
await enqueue("/interact", { handle, action, value });
|
|
214
343
|
}
|
|
215
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Start over. The next send begins a new conversation. Anything queued for
|
|
347
|
+
* the old one is never sent, and anything still open is aborted.
|
|
348
|
+
*/
|
|
349
|
+
function reset() {
|
|
350
|
+
generation++;
|
|
351
|
+
aborter.abort();
|
|
352
|
+
aborter = new AbortController();
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
Object.assign(state, {
|
|
355
|
+
conversationId: null,
|
|
356
|
+
status: "idle",
|
|
357
|
+
blocks: [],
|
|
358
|
+
context: { messages: [], modelTokens: 0, uiTokens: 0 },
|
|
359
|
+
expiresAt: null,
|
|
360
|
+
expired: null,
|
|
361
|
+
});
|
|
362
|
+
state.surfaces.clear();
|
|
363
|
+
notify({ type: "reset" });
|
|
364
|
+
}
|
|
365
|
+
|
|
216
366
|
return {
|
|
217
367
|
state,
|
|
218
368
|
send,
|
|
219
369
|
interact,
|
|
220
370
|
mount,
|
|
371
|
+
reset,
|
|
221
372
|
subscribe(fn) {
|
|
222
373
|
listeners.add(fn);
|
|
223
374
|
return () => listeners.delete(fn);
|
|
224
375
|
},
|
|
225
376
|
};
|
|
226
377
|
}
|
|
378
|
+
|
|
379
|
+
/** "2 hours", "15 minutes" — for a sentence a person reads. */
|
|
380
|
+
function duration(ms) {
|
|
381
|
+
const units = [
|
|
382
|
+
[86_400_000, "day"],
|
|
383
|
+
[3_600_000, "hour"],
|
|
384
|
+
[60_000, "minute"],
|
|
385
|
+
[1_000, "second"],
|
|
386
|
+
];
|
|
387
|
+
for (const [size, name] of units) {
|
|
388
|
+
if (ms >= size) {
|
|
389
|
+
const n = Math.floor(ms / size);
|
|
390
|
+
return `${n} ${name}${n === 1 ? "" : "s"}`;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return "under a second";
|
|
394
|
+
}
|
package/src/transcript.js
CHANGED
|
@@ -46,6 +46,15 @@ function renderBlock(block, chat) {
|
|
|
46
46
|
case "error":
|
|
47
47
|
return h("div", "hai-block hai-error", `error: ${block.message}`);
|
|
48
48
|
|
|
49
|
+
// The conversation is closed, so the notice carries the only way forward.
|
|
50
|
+
case "expired": {
|
|
51
|
+
const el = h("div", "hai-block hai-expired");
|
|
52
|
+
const again = h("button", "hai-new", "Start a new conversation");
|
|
53
|
+
again.onclick = () => chat.reset();
|
|
54
|
+
el.append(h("div", "hai-body", block.message), again);
|
|
55
|
+
return el;
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
// Provenance chrome. The tool row and its surface are one visual unit:
|
|
50
59
|
// what produced this, with what arguments, and what the model got back.
|
|
51
60
|
case "tool": {
|