@haikit/client 0.7.0 → 0.9.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 +50 -1
- package/package.json +1 -1
- package/src/app.d.ts +12 -2
- package/src/app.js +273 -33
- package/src/hai.css +496 -127
- package/src/icons.js +41 -0
- package/src/index.d.ts +4 -0
- package/src/index.js +3 -0
- package/src/transcript.js +76 -14
package/src/icons.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handful of icons the default UI draws. Stroked 24×24 paths in
|
|
3
|
+
* `currentColor`, so each takes the colour of the text around it.
|
|
4
|
+
*
|
|
5
|
+
* Built on call, never at import: the package is importable where there is no
|
|
6
|
+
* DOM.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PATHS = {
|
|
10
|
+
// one line splitting in two: the dual channel
|
|
11
|
+
brand: "M2.5 12H8c3.5 0 4.5-6 9-6h4.5M8 12c3.5 0 4.5 6 9 6h4.5",
|
|
12
|
+
send: "M12 19V5M5.5 11.5 12 5l6.5 6.5",
|
|
13
|
+
plus: "M12 5v14M5 12h14",
|
|
14
|
+
panel: "M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM15 4v16",
|
|
15
|
+
close: "M6 6l12 12M18 6 6 18",
|
|
16
|
+
chevron: "M9 6l6 6-6 6",
|
|
17
|
+
check: "M5 12.5l4.5 4.5L19 7.5",
|
|
18
|
+
alert: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7.5v5.5M12 16.5v.01",
|
|
19
|
+
clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v5l3.5 2",
|
|
20
|
+
reply: "M5 4v7a4 4 0 0 0 4 4h10M15 11l4 4-4 4",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
24
|
+
|
|
25
|
+
/** An inline SVG icon, hidden from assistive technology: the text beside it, or
|
|
26
|
+
* the button's label, says what it means. */
|
|
27
|
+
export function icon(name, className = "hai-icon") {
|
|
28
|
+
const svg = document.createElementNS(SVG, "svg");
|
|
29
|
+
svg.setAttribute("class", className);
|
|
30
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
31
|
+
svg.setAttribute("fill", "none");
|
|
32
|
+
svg.setAttribute("stroke", "currentColor");
|
|
33
|
+
svg.setAttribute("stroke-width", "2");
|
|
34
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
35
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
36
|
+
svg.setAttribute("aria-hidden", "true");
|
|
37
|
+
const path = document.createElementNS(SVG, "path");
|
|
38
|
+
path.setAttribute("d", PATHS[name]);
|
|
39
|
+
svg.append(path);
|
|
40
|
+
return svg;
|
|
41
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface MountCtx {
|
|
|
37
37
|
handle: string;
|
|
38
38
|
mode: SurfaceMode;
|
|
39
39
|
state: SurfaceState;
|
|
40
|
+
/** What the user picked, when the surface is mounted already frozen. */
|
|
41
|
+
selection?: unknown;
|
|
40
42
|
send(action: string, value: unknown): Promise<void>;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -78,6 +80,8 @@ export interface SurfaceRecord {
|
|
|
78
80
|
mode: SurfaceMode;
|
|
79
81
|
state: SurfaceState;
|
|
80
82
|
props: unknown | null;
|
|
83
|
+
/** What the user picked, once frozen. */
|
|
84
|
+
selection?: unknown;
|
|
81
85
|
instance: SurfaceInstance | null;
|
|
82
86
|
/** The element it was last mounted into. */
|
|
83
87
|
element: HTMLElement | null;
|
package/src/index.js
CHANGED
|
@@ -222,6 +222,8 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
222
222
|
const surface = state.surfaces.get(event.handle);
|
|
223
223
|
if (surface) {
|
|
224
224
|
surface.state = event.state;
|
|
225
|
+
// kept, so a surface mounted again once frozen still knows the answer
|
|
226
|
+
surface.selection = event.selection;
|
|
225
227
|
surface.instance?.freeze?.(event.selection);
|
|
226
228
|
}
|
|
227
229
|
break;
|
|
@@ -320,6 +322,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
320
322
|
handle,
|
|
321
323
|
mode: surface.mode,
|
|
322
324
|
state: surface.state,
|
|
325
|
+
selection: surface.selection,
|
|
323
326
|
// The ONLY channel a component has to the server. It passes an action
|
|
324
327
|
// name declared in the contract — never a tool, never a handler.
|
|
325
328
|
send: (action, value) => interact(handle, action, value),
|
package/src/transcript.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Everything uses textContent. Tool payloads are untrusted input.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { icon } from "./icons.js";
|
|
10
|
+
|
|
9
11
|
export const h = (tag, className, text) => {
|
|
10
12
|
const node = document.createElement(tag);
|
|
11
13
|
if (className) node.className = className;
|
|
@@ -75,9 +77,53 @@ export function closeTranscript(root) {
|
|
|
75
77
|
function appearance(block, chat) {
|
|
76
78
|
// a surface shows a skeleton until its props arrive, and then itself for good
|
|
77
79
|
if (block.kind === "ui") return chat.state.surfaces.get(block.handle)?.props ? "mounted" : "loading";
|
|
80
|
+
if (block.kind === "tool") return JSON.stringify({ ...block, status: toolStatus(block, chat) });
|
|
78
81
|
return JSON.stringify(block);
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* What a tool row says of its call. The server leaves an elicit tool's row
|
|
86
|
+
* `awaiting` for good; the question has closed once its surface is frozen —
|
|
87
|
+
* answered, or typed over — or the conversation is out of date. An answered
|
|
88
|
+
* question stays answered when the conversation goes out of date. A tool can
|
|
89
|
+
* show a display surface before its question, and that one never freezes, so
|
|
90
|
+
* it is the elicit surface that says.
|
|
91
|
+
*/
|
|
92
|
+
function toolStatus(block, chat) {
|
|
93
|
+
if (block.status !== "awaiting") return block.status;
|
|
94
|
+
const { blocks, surfaces, expired } = chat.state;
|
|
95
|
+
// Found once per row and kept: every answered question keeps its row
|
|
96
|
+
// `awaiting`, and this runs for each of them on every streamed event.
|
|
97
|
+
let handle = questions.get(block);
|
|
98
|
+
if (handle === undefined) {
|
|
99
|
+
handle = blocks.find(
|
|
100
|
+
(b) => b.kind === "ui" && b.toolId === block.id && surfaces.get(b.handle)?.mode === "elicit",
|
|
101
|
+
)?.handle;
|
|
102
|
+
if (handle !== undefined) questions.set(block, handle);
|
|
103
|
+
}
|
|
104
|
+
if (surfaces.get(handle)?.state === "frozen") return "resolved";
|
|
105
|
+
return expired ? "expired" : "awaiting";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A tool row's block → the handle of the question it asked, once seen. */
|
|
109
|
+
const questions = new WeakMap();
|
|
110
|
+
|
|
111
|
+
const STATUS_TEXT = {
|
|
112
|
+
running: () => "running…",
|
|
113
|
+
awaiting: () => "awaiting you",
|
|
114
|
+
resolved: () => "resolved",
|
|
115
|
+
expired: () => "out of date",
|
|
116
|
+
error: (ms) => `failed · ${ms} ms`,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function statusMark(status) {
|
|
120
|
+
const mark = h("span", "hai-ticon");
|
|
121
|
+
if (status === "running") mark.append(h("span", "hai-spinner"));
|
|
122
|
+
else if (status === "awaiting") mark.append(h("span", "hai-pulse"));
|
|
123
|
+
else mark.append(icon(status === "error" ? "alert" : status === "expired" ? "clock" : "check"));
|
|
124
|
+
return mark;
|
|
125
|
+
}
|
|
126
|
+
|
|
81
127
|
/**
|
|
82
128
|
* Put `elements` in order under `root`, moving none that is already there:
|
|
83
129
|
* one taken out and put back loses its scroll position and focus, and any
|
|
@@ -204,43 +250,54 @@ function renderBlock(block, chat) {
|
|
|
204
250
|
return el;
|
|
205
251
|
}
|
|
206
252
|
|
|
253
|
+
// The user's answer, given in a surface rather than typed: it went back to
|
|
254
|
+
// the model as the tool's result.
|
|
207
255
|
case "interaction": {
|
|
208
256
|
const el = h("div", "hai-block hai-interaction");
|
|
209
|
-
|
|
257
|
+
const arrow = h("span", "hai-arrow");
|
|
258
|
+
arrow.append(icon("reply"));
|
|
259
|
+
el.append(arrow, h("span", "hai-body", block.label));
|
|
210
260
|
return el;
|
|
211
261
|
}
|
|
212
262
|
|
|
213
|
-
case "error":
|
|
214
|
-
|
|
263
|
+
case "error": {
|
|
264
|
+
const el = h("div", "hai-block hai-error");
|
|
265
|
+
el.setAttribute("role", "alert");
|
|
266
|
+
el.append(icon("alert"), h("div", "hai-body", block.message));
|
|
267
|
+
return el;
|
|
268
|
+
}
|
|
215
269
|
|
|
216
270
|
// The conversation is closed, so the notice carries the only way forward.
|
|
217
271
|
case "expired": {
|
|
218
272
|
const el = h("div", "hai-block hai-expired");
|
|
219
273
|
const again = h("button", "hai-new", "Start a new conversation");
|
|
274
|
+
again.type = "button";
|
|
220
275
|
again.onclick = () => chat.reset();
|
|
221
|
-
el.append(h("div", "hai-body", block.message), again);
|
|
276
|
+
el.append(icon("clock"), h("div", "hai-body", block.message), again);
|
|
222
277
|
return el;
|
|
223
278
|
}
|
|
224
279
|
|
|
225
280
|
// Provenance chrome. The tool row and its surface are one visual unit:
|
|
226
281
|
// what produced this, with what arguments, and what the model got back.
|
|
227
282
|
case "tool": {
|
|
228
|
-
const
|
|
283
|
+
const status = toolStatus(block, chat);
|
|
284
|
+
const el = h("details", `hai-block hai-tool hai-status-${status}`);
|
|
229
285
|
const summary = h("summary");
|
|
230
286
|
summary.append(
|
|
231
|
-
|
|
287
|
+
statusMark(status),
|
|
232
288
|
h("span", "hai-tname", block.name),
|
|
233
289
|
h("span", "hai-targs", JSON.stringify(block.input)),
|
|
234
|
-
h(
|
|
235
|
-
|
|
236
|
-
"hai-tstatus",
|
|
237
|
-
block.status === "running" ? "…" : block.status === "awaiting" ? "awaiting user" : `${block.ms}ms`,
|
|
238
|
-
),
|
|
290
|
+
h("span", "hai-tstatus", (STATUS_TEXT[status] ?? ((ms) => `${ms} ms`))(block.ms)),
|
|
291
|
+
icon("chevron", "hai-icon hai-chevron"),
|
|
239
292
|
);
|
|
240
293
|
el.append(summary);
|
|
241
294
|
const detail = h("div", "hai-tool-detail");
|
|
242
|
-
detail.append(
|
|
243
|
-
|
|
295
|
+
detail.append(
|
|
296
|
+
h("div", "hai-label", "input"),
|
|
297
|
+
h("pre", "hai-code", JSON.stringify(block.input ?? {}, null, 2)),
|
|
298
|
+
h("div", "hai-label", "→ tool_result — all the model receives"),
|
|
299
|
+
h("pre", "hai-digest", block.result ?? "…"),
|
|
300
|
+
);
|
|
244
301
|
el.append(detail);
|
|
245
302
|
return el;
|
|
246
303
|
}
|
|
@@ -249,10 +306,15 @@ function renderBlock(block, chat) {
|
|
|
249
306
|
const el = h("div", "hai-surface");
|
|
250
307
|
el.dataset.handle = block.handle;
|
|
251
308
|
const surface = chat.state.surfaces.get(block.handle);
|
|
309
|
+
if (surface) el.dataset.component = surface.component;
|
|
252
310
|
if (surface?.props) {
|
|
253
311
|
queueMicrotask(() => chat.mount(block.handle, el));
|
|
254
312
|
} else {
|
|
255
|
-
el.
|
|
313
|
+
el.setAttribute("aria-busy", "true");
|
|
314
|
+
const skeleton = h("div", "hai-skeleton");
|
|
315
|
+
skeleton.append(h("div", "hai-skeleton-label", `${surface?.component ?? "surface"} · loading…`));
|
|
316
|
+
for (let i = 0; i < 3; i++) skeleton.append(h("div", "hai-shimmer"));
|
|
317
|
+
el.append(skeleton);
|
|
256
318
|
}
|
|
257
319
|
return el;
|
|
258
320
|
}
|