@haikit/client 0.15.0 → 0.17.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 +22 -5
- package/package.json +1 -1
- package/src/index.d.ts +12 -1
- package/src/index.js +84 -31
- package/src/transcript.js +3 -0
package/README.md
CHANGED
|
@@ -76,10 +76,19 @@ 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.
|
|
83
92
|
|
|
84
93
|
`chat.close()` is for good: anything open is aborted, every surface is
|
|
85
94
|
unmounted, and subscribers hear `{ type: "closed" }`. `mountChat` then removes
|
|
@@ -129,7 +138,15 @@ The server closes a conversation once any surface passes its freshness window.
|
|
|
129
138
|
The client knows each window too, so a tab left open closes on time instead of
|
|
130
139
|
looking live until a click is refused: `state.expired` holds the notice,
|
|
131
140
|
`send` and `interact` stop sending, and each surface's element is made `inert`
|
|
132
|
-
with `data-expired` set.
|
|
141
|
+
with `data-expired` set.
|
|
142
|
+
|
|
143
|
+
The browser counts each window from when it sent the request that rendered
|
|
144
|
+
the surface, which is never later than the server's own stamp, so its
|
|
145
|
+
deadline is never later than the server's. Each surface also comes with
|
|
146
|
+
`ageMs`, how long ago the server stored it: the time its digest took, plus,
|
|
147
|
+
for one replayed to a stream that joined a wake turn part way, how long it
|
|
148
|
+
waited. The window is counted from whichever is earlier. That's a duration,
|
|
149
|
+
not a time, so the two clocks are never compared. `chat.reset()` starts a new conversation; the default
|
|
133
150
|
transcript renders the notice with a button that calls it.
|
|
134
151
|
|
|
135
152
|
## Serving it
|
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
|
@@ -228,13 +228,9 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
228
228
|
state.notices.push(notice);
|
|
229
229
|
const block = { kind: "notice", id: `n:${event.seq}`, ...notice };
|
|
230
230
|
// beside its surface — after any notices already there — or at the end
|
|
231
|
-
const at = event.handle === undefined ? -1 : state.blocks.findIndex((b) => b.
|
|
231
|
+
const at = event.handle === undefined ? -1 : state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
|
|
232
232
|
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
|
-
}
|
|
233
|
+
else state.blocks.splice(afterNotices(at), 0, block);
|
|
238
234
|
break;
|
|
239
235
|
}
|
|
240
236
|
|
|
@@ -271,40 +267,70 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
271
267
|
break;
|
|
272
268
|
}
|
|
273
269
|
|
|
274
|
-
case "ui_open":
|
|
270
|
+
case "ui_open": {
|
|
275
271
|
if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
272
|
+
// A revision of a surface on screen takes over its record in place:
|
|
273
|
+
// its component, element and state stay, its handle moves on, and its
|
|
274
|
+
// props stay until the new ones arrive, so the transcript keeps the
|
|
275
|
+
// same element rather than mounting afresh.
|
|
276
|
+
const old = event.replaces === undefined ? undefined : state.surfaces.get(event.replaces);
|
|
277
|
+
let surface;
|
|
278
|
+
if (old) {
|
|
279
|
+
state.surfaces.delete(old.handle);
|
|
280
|
+
Object.assign(old, { handle: event.handle, component: event.component, version: event.version, revising: true });
|
|
281
|
+
state.surfaces.set(event.handle, (surface = old));
|
|
282
|
+
const block = state.blocks.find((b) => b.kind === "ui" && b.handle === event.replaces);
|
|
283
|
+
if (block) block.handle = event.handle;
|
|
284
|
+
} else {
|
|
285
|
+
surface = {
|
|
286
|
+
handle: event.handle,
|
|
287
|
+
component: event.component,
|
|
288
|
+
version: event.version,
|
|
289
|
+
mode: event.mode,
|
|
290
|
+
state: "live",
|
|
291
|
+
props: null,
|
|
292
|
+
instance: null,
|
|
293
|
+
element: null,
|
|
294
|
+
};
|
|
295
|
+
state.surfaces.set(event.handle, surface);
|
|
296
|
+
state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
|
|
297
|
+
}
|
|
287
298
|
// Notices come on a stream of their own, so one can arrive before the
|
|
288
299
|
// surface it names: it went to the end then, and moves under it now.
|
|
289
300
|
// Moved within the same array, which the transcript would otherwise
|
|
290
301
|
// take for a new conversation's.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
302
|
+
const early = state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle);
|
|
303
|
+
for (const notice of early) state.blocks.splice(state.blocks.indexOf(notice), 1);
|
|
304
|
+
const at = state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
|
|
305
|
+
if (at >= 0) state.blocks.splice(afterNotices(at), 0, ...early);
|
|
306
|
+
else state.blocks.push(...early);
|
|
307
|
+
// Each surface keeps its own deadline: from when the request that
|
|
308
|
+
// rendered it was sent or, by its age, from when it was stored,
|
|
309
|
+
// whichever is earlier. A revision brings its own, and the one it
|
|
310
|
+
// replaced stops counting.
|
|
295
311
|
if (typeof event.staleAfterMs === "number") {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
312
|
+
const from = typeof event.ageMs === "number" ? Math.min(sentAt, Date.now() - event.ageMs) : sentAt;
|
|
313
|
+
surface.deadline = from + event.staleAfterMs;
|
|
314
|
+
surface.window = event.staleAfterMs;
|
|
315
|
+
} else {
|
|
316
|
+
surface.deadline = null;
|
|
302
317
|
}
|
|
318
|
+
refreshDeadline();
|
|
303
319
|
break;
|
|
320
|
+
}
|
|
304
321
|
|
|
305
322
|
case "ui_props": {
|
|
306
323
|
const surface = state.surfaces.get(event.handle);
|
|
307
|
-
if (surface)
|
|
324
|
+
if (!surface) break;
|
|
325
|
+
surface.props = event.props;
|
|
326
|
+
if (surface.revising) {
|
|
327
|
+
// A revision's props reach the component already mounted: through
|
|
328
|
+
// its `update`, keeping its own state, or by mounting it again.
|
|
329
|
+
surface.revising = false;
|
|
330
|
+
const { instance, element } = surface;
|
|
331
|
+
if (instance && typeof instance.update === "function") attempt(() => instance.update(event.props));
|
|
332
|
+
else if (instance && element) mount(surface.handle, element);
|
|
333
|
+
}
|
|
308
334
|
break;
|
|
309
335
|
}
|
|
310
336
|
|
|
@@ -496,6 +522,29 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
496
522
|
`${duration(closingWindow)}. Start a new conversation for current results.`,
|
|
497
523
|
});
|
|
498
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Where a notice for the surface at `at` goes: after the notices already
|
|
527
|
+
* beside it, which may name an earlier revision of it, so they keep the
|
|
528
|
+
* order they arrived in. A notice that names no surface sits at the end,
|
|
529
|
+
* not beside one, and isn't counted among them.
|
|
530
|
+
*/
|
|
531
|
+
function afterNotices(at) {
|
|
532
|
+
let i = at + 1;
|
|
533
|
+
while (state.blocks[i]?.kind === "notice" && state.blocks[i].handle !== undefined) i++;
|
|
534
|
+
return i;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The earliest deadline among the surfaces showing, and which window set it. */
|
|
538
|
+
function refreshDeadline() {
|
|
539
|
+
let soonest = null;
|
|
540
|
+
for (const surface of state.surfaces.values()) {
|
|
541
|
+
if (surface.deadline != null && (soonest === null || surface.deadline < soonest.deadline)) soonest = surface;
|
|
542
|
+
}
|
|
543
|
+
state.expiresAt = soonest?.deadline ?? null;
|
|
544
|
+
if (soonest) closingWindow = soonest.window;
|
|
545
|
+
schedule();
|
|
546
|
+
}
|
|
547
|
+
|
|
499
548
|
// One timer, for the earliest deadline. setTimeout fires at once for delays
|
|
500
549
|
// past about 24.8 days, so a long wait is taken in steps.
|
|
501
550
|
function schedule() {
|
|
@@ -542,13 +591,17 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
|
|
|
542
591
|
element.replaceChildren();
|
|
543
592
|
surface.element = element;
|
|
544
593
|
surface.instance = definition.mount(element, surface.props, {
|
|
545
|
-
|
|
594
|
+
// the surface as it is now, as `send` below: a revision moves it on
|
|
595
|
+
get handle() {
|
|
596
|
+
return surface.handle;
|
|
597
|
+
},
|
|
546
598
|
mode: surface.mode,
|
|
547
599
|
state: surface.state,
|
|
548
600
|
selection: surface.selection,
|
|
549
601
|
// The ONLY channel a component has to the server. It passes an action
|
|
550
602
|
// name declared in the contract — never a tool, never a handler.
|
|
551
|
-
|
|
603
|
+
// and names the surface as it is now: a revision moves it on.
|
|
604
|
+
send: (action, value) => interact(surface.handle, action, value),
|
|
552
605
|
});
|
|
553
606
|
if (state.expired) seal(surface);
|
|
554
607
|
return surface.instance;
|
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.
|