@haikit/client 0.8.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/transcript.js +76 -14
package/README.md
CHANGED
|
@@ -13,11 +13,60 @@ API — it is built from `createChat` + `renderTranscript`, and dropping to thos
|
|
|
13
13
|
when the default shell stops fitting is the expected path.
|
|
14
14
|
|
|
15
15
|
`hai.css` styles **only what the framework renders** — transcript blocks, tool
|
|
16
|
-
rows, the surface container, shell, composer,
|
|
16
|
+
rows, the surface container, shell, composer, debug drawer. It deliberately does
|
|
17
17
|
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
|
+
## The default UI
|
|
22
|
+
|
|
23
|
+
`mountChat` gives you a header with **New chat** and a **Context** button that
|
|
24
|
+
shows how many tokens the model holds, a centred conversation, and a composer
|
|
25
|
+
that grows as you type.
|
|
26
|
+
|
|
27
|
+
**Context** opens the *model context debug drawer*: every message, tool call
|
|
28
|
+
and digest the model receives, and how much of the payload stayed out of
|
|
29
|
+
context. It starts closed and slides in from the right. On a wide screen the
|
|
30
|
+
chat makes room for it, and on a narrow one it opens over the chat. Escape or
|
|
31
|
+
its close button shuts it. It opens again after a reload if it was open
|
|
32
|
+
before. `inspector: false` leaves it out.
|
|
33
|
+
|
|
34
|
+
Each tool row says where its call stands: running, `awaiting you` while the turn
|
|
35
|
+
is parked on its surface, `resolved` once that surface has been answered or
|
|
36
|
+
typed over, or how long it took. Expand it to see the input and the
|
|
37
|
+
`tool_result`, which is everything the model received.
|
|
38
|
+
|
|
39
|
+
It is light or dark with the system. To pin it, put `data-hai-theme="dark"` (or
|
|
40
|
+
`"light"`) on the `<html>` element, or pass `theme: "dark"` to `mountChat`.
|
|
41
|
+
|
|
42
|
+
### Tokens
|
|
43
|
+
|
|
44
|
+
Every token is set at zero specificity, so a plain `:root` rule in your own
|
|
45
|
+
sheet wins. Give a colour one value per scheme with `light-dark()`:
|
|
46
|
+
|
|
47
|
+
```css
|
|
48
|
+
:root { --hai-accent: #b07cff; }
|
|
49
|
+
@supports (color: light-dark(#000, #fff)) {
|
|
50
|
+
:root { --hai-accent: light-dark(#6d28d9, #b07cff); }
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Keep the plain value first. A browser without `light-dark()` gets hai.css's
|
|
55
|
+
dark scheme, and an override it can't read would leave that token with no
|
|
56
|
+
colour at all.
|
|
57
|
+
|
|
58
|
+
| | |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `--hai-bg` `--hai-panel` `--hai-panel-2` | page, cards, secondary panels |
|
|
61
|
+
| `--hai-fg` `--hai-dim` | text, secondary text |
|
|
62
|
+
| `--hai-line` `--hai-line-soft` | borders, row dividers |
|
|
63
|
+
| `--hai-accent` `--hai-on-accent` | the accent, and text drawn on it |
|
|
64
|
+
| `--hai-good` `--hai-warn` `--hai-bad` | status colours; each, and the accent, has a `-soft` tint for backgrounds |
|
|
65
|
+
| `--hai-hover` `--hai-selected` | row hover and picked-row backgrounds, for your surfaces |
|
|
66
|
+
| `--hai-user-bg` `--hai-code-bg` `--hai-shadow` | user bubble, digest blocks, card shadow |
|
|
67
|
+
| `--hai-radius` `--hai-sans` `--hai-mono` | shape and type |
|
|
68
|
+
| `--hai-column` `--hai-inspector-width` | conversation width, debug drawer width |
|
|
69
|
+
|
|
21
70
|
## Surface lifecycle
|
|
22
71
|
|
|
23
72
|
The default transcript keeps each block's element for as long as what it
|
package/package.json
CHANGED
package/src/app.d.ts
CHANGED
|
@@ -9,10 +9,20 @@ export interface MountChatOptions {
|
|
|
9
9
|
subtitle?: string;
|
|
10
10
|
/** Clickable starter prompts shown on the empty transcript. */
|
|
11
11
|
suggestions?: string[];
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Include the model context debug drawer, opened from the header. Default
|
|
14
|
+
* true — keep it while developing. It starts closed, and opens again after a
|
|
15
|
+
* reload if it was open before.
|
|
16
|
+
*/
|
|
13
17
|
inspector?: boolean;
|
|
14
18
|
placeholder?: string;
|
|
15
19
|
emptyText?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Pin the page to a colour scheme, by setting `data-hai-theme` on the root
|
|
22
|
+
* element until the chat closes. Unset, it follows the system's light or
|
|
23
|
+
* dark setting, or a `data-hai-theme` already in the page's markup.
|
|
24
|
+
*/
|
|
25
|
+
theme?: "light" | "dark";
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
/**
|
|
@@ -30,7 +40,7 @@ export interface MountChatOptions {
|
|
|
30
40
|
*/
|
|
31
41
|
export function mountChat(options: MountChatOptions): Chat;
|
|
32
42
|
|
|
33
|
-
/**
|
|
43
|
+
/** What the model context debug drawer shows: makes the dual channel visible. */
|
|
34
44
|
export function renderInspector(
|
|
35
45
|
el: HTMLElement,
|
|
36
46
|
context?: { messages?: any[]; modelTokens?: number; uiTokens?: number },
|
package/src/app.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* hai-client — the default UI.
|
|
3
3
|
*
|
|
4
4
|
* `mountChat()` builds the shell, wires the composer, renders the transcript and
|
|
5
|
-
* (optionally) the context
|
|
5
|
+
* (optionally) the model context debug drawer. It is the ten-line path to a
|
|
6
|
+
* running app.
|
|
6
7
|
*
|
|
7
8
|
* import { mountChat } from "/hai-client/app.js";
|
|
8
9
|
* import { registry } from "./components.js";
|
|
@@ -16,6 +17,48 @@
|
|
|
16
17
|
|
|
17
18
|
import { createChat } from "./index.js";
|
|
18
19
|
import { renderTranscript, closeTranscript, h } from "./transcript.js";
|
|
20
|
+
import { icon } from "./icons.js";
|
|
21
|
+
|
|
22
|
+
/** Below this width the drawer opens over the chat rather than beside it. */
|
|
23
|
+
const WIDE = "(min-width: 900px)";
|
|
24
|
+
|
|
25
|
+
/** The drawer's name, as its heading shows it and assistive technology says it. */
|
|
26
|
+
const DRAWER = "Model context debug drawer";
|
|
27
|
+
|
|
28
|
+
/** Where the drawer remembers being open, so a reload while developing keeps it. */
|
|
29
|
+
const REMEMBER = "hai:debug-drawer";
|
|
30
|
+
|
|
31
|
+
/** Numbers the inspector's ids, so two shells on a page never share one. */
|
|
32
|
+
let shells = 0;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The page's scheme pin is one attribute shared by every chat on it. It
|
|
36
|
+
* follows the newest themed chat still open; when the last one closes, the
|
|
37
|
+
* page gets back the pin it had before the first. A pin the page set itself
|
|
38
|
+
* in the meantime is the page's, and closing a chat leaves it alone.
|
|
39
|
+
*/
|
|
40
|
+
const pinning = { chats: [], pageOwn: undefined, ours: undefined };
|
|
41
|
+
|
|
42
|
+
function pin(chat) {
|
|
43
|
+
if (!pinning.chats.length) pinning.pageOwn = document.documentElement.dataset.haiTheme;
|
|
44
|
+
pinning.chats.push(chat);
|
|
45
|
+
setPin(chat.theme);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function unpin(chat) {
|
|
49
|
+
const i = pinning.chats.indexOf(chat);
|
|
50
|
+
if (i < 0) return;
|
|
51
|
+
pinning.chats.splice(i, 1);
|
|
52
|
+
if (document.documentElement.dataset.haiTheme !== pinning.ours) return;
|
|
53
|
+
setPin(pinning.chats.at(-1)?.theme ?? pinning.pageOwn);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setPin(theme) {
|
|
57
|
+
const page = document.documentElement;
|
|
58
|
+
if (theme === undefined) delete page.dataset.haiTheme;
|
|
59
|
+
else page.dataset.haiTheme = theme;
|
|
60
|
+
pinning.ours = theme;
|
|
61
|
+
}
|
|
19
62
|
|
|
20
63
|
/**
|
|
21
64
|
* @param {{
|
|
@@ -28,6 +71,7 @@ import { renderTranscript, closeTranscript, h } from "./transcript.js";
|
|
|
28
71
|
* inspector?: boolean,
|
|
29
72
|
* placeholder?: string,
|
|
30
73
|
* emptyText?: string,
|
|
74
|
+
* theme?: "light" | "dark",
|
|
31
75
|
* }} options
|
|
32
76
|
*/
|
|
33
77
|
export function mountChat({
|
|
@@ -40,25 +84,49 @@ export function mountChat({
|
|
|
40
84
|
inspector = true,
|
|
41
85
|
placeholder = "message…",
|
|
42
86
|
emptyText = "",
|
|
87
|
+
theme,
|
|
43
88
|
}) {
|
|
44
89
|
const chat = createChat({ endpoint, registry });
|
|
90
|
+
const id = `hai-${++shells}`;
|
|
91
|
+
|
|
92
|
+
// Unset, the page follows the system. A pin goes on the root, where hai.css
|
|
93
|
+
// sets the tokens: a minifier that lowers light-dark() resolves them there,
|
|
94
|
+
// so a pin on the shell alone would not reach them.
|
|
95
|
+
const pinned = theme === "light" || theme === "dark" ? { theme } : null;
|
|
96
|
+
if (pinned) pin(pinned);
|
|
45
97
|
|
|
46
98
|
// ── shell ──────────────────────────────────────────────────────────
|
|
47
99
|
const shell = h("div", "hai-shell");
|
|
48
100
|
|
|
49
101
|
const top = h("header", "hai-top");
|
|
50
|
-
const
|
|
51
|
-
|
|
102
|
+
const brand = h("div", "hai-brand");
|
|
103
|
+
brand.append(icon("brand", "hai-icon hai-mark"), h("span", "hai-title", title));
|
|
104
|
+
if (subtitle) brand.append(h("span", "hai-subtitle", subtitle));
|
|
105
|
+
|
|
106
|
+
const modelEl = h("span", "hai-model");
|
|
107
|
+
modelEl.hidden = true;
|
|
108
|
+
const newBtn = button("hai-btn", "plus", "New chat");
|
|
109
|
+
newBtn.onclick = () => chat.reset();
|
|
110
|
+
const actions = h("div", "hai-actions");
|
|
111
|
+
actions.append(modelEl, newBtn);
|
|
112
|
+
top.append(brand, actions);
|
|
52
113
|
|
|
53
114
|
const transcript = h("div", "hai-transcript");
|
|
54
115
|
const statusEl = h("div", "hai-status");
|
|
116
|
+
statusEl.setAttribute("role", "status");
|
|
55
117
|
const input = h("textarea", "hai-input");
|
|
56
118
|
input.rows = 1;
|
|
57
119
|
input.placeholder = placeholder;
|
|
58
|
-
|
|
120
|
+
input.setAttribute("aria-label", "Message");
|
|
121
|
+
const sendBtn = h("button", "hai-send");
|
|
122
|
+
sendBtn.type = "button";
|
|
123
|
+
sendBtn.setAttribute("aria-label", "Send");
|
|
124
|
+
sendBtn.append(icon("send"));
|
|
59
125
|
|
|
126
|
+
const box = h("div", "hai-composer-box");
|
|
127
|
+
box.append(input, sendBtn);
|
|
60
128
|
const composer = h("div", "hai-composer");
|
|
61
|
-
composer.append(
|
|
129
|
+
composer.append(box);
|
|
62
130
|
|
|
63
131
|
const chatPane = h("section", "hai-chat");
|
|
64
132
|
chatPane.append(transcript, statusEl, composer);
|
|
@@ -67,19 +135,103 @@ export function mountChat({
|
|
|
67
135
|
main.append(chatPane);
|
|
68
136
|
|
|
69
137
|
let inspectorBody = null;
|
|
138
|
+
let ctxCount = null;
|
|
139
|
+
let releaseDrawer = () => {};
|
|
70
140
|
if (inspector) {
|
|
141
|
+
// The model context debug drawer: closed until asked for, it slides in
|
|
142
|
+
// from the right, beside the chat on a wide screen and over it on a
|
|
143
|
+
// narrow one. The header button that opens it says how much the model
|
|
144
|
+
// holds, open or not.
|
|
71
145
|
const aside = h("aside", "hai-inspector");
|
|
72
|
-
|
|
73
|
-
|
|
146
|
+
aside.id = `${id}-inspector`;
|
|
147
|
+
aside.setAttribute("aria-label", DRAWER);
|
|
148
|
+
const head = h("div", "hai-inspector-head");
|
|
149
|
+
const heading = h("div", "hai-inspector-title", DRAWER);
|
|
150
|
+
heading.append(h("span", null, "everything the model sees"));
|
|
151
|
+
const hide = h("button", "hai-icon-btn");
|
|
152
|
+
hide.type = "button";
|
|
153
|
+
hide.setAttribute("aria-label", `Close ${DRAWER.toLowerCase()}`);
|
|
154
|
+
hide.append(icon("close"));
|
|
155
|
+
head.append(heading, hide);
|
|
74
156
|
inspectorBody = h("div", "hai-inspector-body");
|
|
75
|
-
aside.append(
|
|
157
|
+
aside.append(head, inspectorBody);
|
|
76
158
|
main.append(aside);
|
|
159
|
+
|
|
160
|
+
const toggle = button("hai-btn hai-ctx-toggle", "panel", "Context");
|
|
161
|
+
ctxCount = h("span", "hai-ctx-count");
|
|
162
|
+
toggle.append(ctxCount);
|
|
163
|
+
toggle.title = DRAWER;
|
|
164
|
+
toggle.setAttribute("aria-controls", aside.id);
|
|
165
|
+
actions.append(toggle);
|
|
166
|
+
|
|
167
|
+
const wideQuery = typeof matchMedia === "function" ? matchMedia(WIDE) : null;
|
|
168
|
+
const wide = () => !wideQuery || wideQuery.matches;
|
|
169
|
+
const isOpen = () => main.classList.contains("inspector-open");
|
|
170
|
+
// Over the chat, on a narrow screen, the drawer is all there is to reach:
|
|
171
|
+
// the chat behind it is inert until it closes or the screen widens.
|
|
172
|
+
const sync = () => {
|
|
173
|
+
chatPane.inert = isOpen() && !wide();
|
|
174
|
+
};
|
|
175
|
+
const set = (open) => {
|
|
176
|
+
main.classList.toggle("inspector-open", open);
|
|
177
|
+
toggle.setAttribute("aria-expanded", String(open));
|
|
178
|
+
// out of reach as soon as it starts to close, not once it has slid away
|
|
179
|
+
aside.inert = !open;
|
|
180
|
+
sync();
|
|
181
|
+
};
|
|
182
|
+
const show = (open) => {
|
|
183
|
+
set(open);
|
|
184
|
+
try {
|
|
185
|
+
localStorage.setItem(REMEMBER, open ? "open" : "closed");
|
|
186
|
+
} catch {}
|
|
187
|
+
};
|
|
188
|
+
toggle.onclick = () => {
|
|
189
|
+
const open = !isOpen();
|
|
190
|
+
show(open);
|
|
191
|
+
if (open) hide.focus();
|
|
192
|
+
};
|
|
193
|
+
hide.onclick = () => {
|
|
194
|
+
show(false);
|
|
195
|
+
toggle.focus();
|
|
196
|
+
};
|
|
197
|
+
// a tap on the chat it covers closes it, as a tap outside a dialog does,
|
|
198
|
+
// and focus goes back to Context, as it does from the close button
|
|
199
|
+
main.addEventListener("click", (e) => {
|
|
200
|
+
if (e.target === main && chatPane.inert) hide.onclick();
|
|
201
|
+
});
|
|
202
|
+
// Escape closes it from anywhere in the shell, unless something there
|
|
203
|
+
// used the key first, such as a surface closing a menu of its own. Focus
|
|
204
|
+
// goes back to Context from inside the drawer; a reader typing in the
|
|
205
|
+
// composer keeps their place.
|
|
206
|
+
shell.addEventListener("keydown", (e) => {
|
|
207
|
+
if (e.key !== "Escape" || e.defaultPrevented || !isOpen()) return;
|
|
208
|
+
const inside = aside.contains(document.activeElement);
|
|
209
|
+
show(false);
|
|
210
|
+
if (inside) toggle.focus();
|
|
211
|
+
});
|
|
212
|
+
wideQuery?.addEventListener("change", sync);
|
|
213
|
+
releaseDrawer = () => wideQuery?.removeEventListener("change", sync);
|
|
214
|
+
|
|
215
|
+
// Open again after a reload if it was open before, but only beside the
|
|
216
|
+
// chat: over it, on a narrow screen, it would hide the page it debugs.
|
|
217
|
+
let remembered = null;
|
|
218
|
+
try {
|
|
219
|
+
remembered = localStorage.getItem(REMEMBER);
|
|
220
|
+
} catch {}
|
|
221
|
+
set(remembered === "open" && wide());
|
|
77
222
|
}
|
|
78
223
|
|
|
79
224
|
shell.append(top, main);
|
|
80
225
|
root.replaceChildren(shell);
|
|
81
226
|
|
|
82
|
-
// ──
|
|
227
|
+
// ── composer ───────────────────────────────────────────────────────
|
|
228
|
+
// The input grows with what is typed, up to the height hai.css allows.
|
|
229
|
+
const fit = () => {
|
|
230
|
+
input.style.height = "auto";
|
|
231
|
+
input.style.height = `${input.scrollHeight}px`;
|
|
232
|
+
};
|
|
233
|
+
input.addEventListener("input", fit);
|
|
234
|
+
|
|
83
235
|
const send = () => {
|
|
84
236
|
const text = input.value.trim();
|
|
85
237
|
if (!text) return;
|
|
@@ -88,21 +240,29 @@ export function mountChat({
|
|
|
88
240
|
// this already knows: keep the text for the new conversation instead
|
|
89
241
|
if (chat.state.expired) return;
|
|
90
242
|
input.value = "";
|
|
243
|
+
fit();
|
|
91
244
|
// A message queued behind a long turn can still be turned away later, if
|
|
92
245
|
// the conversation goes out of date before its turn comes. Give the text
|
|
93
246
|
// back then, unless something new has been typed since.
|
|
94
247
|
sending.then((sent) => {
|
|
95
|
-
if (!sent && !input.value)
|
|
248
|
+
if (!sent && !input.value) {
|
|
249
|
+
input.value = text;
|
|
250
|
+
fit();
|
|
251
|
+
}
|
|
96
252
|
});
|
|
97
253
|
};
|
|
98
254
|
|
|
255
|
+
// ── empty state ────────────────────────────────────────────────────
|
|
99
256
|
function emptyState() {
|
|
100
257
|
const empty = h("div", "hai-empty");
|
|
258
|
+
if (!emptyText && !suggestions.length) return empty;
|
|
259
|
+
empty.append(icon("brand", "hai-icon hai-empty-mark"));
|
|
101
260
|
if (emptyText) empty.append(h("p", null, emptyText));
|
|
102
261
|
if (suggestions.length) {
|
|
103
262
|
const row = h("div", "hai-suggestions");
|
|
104
263
|
for (const s of suggestions) {
|
|
105
264
|
const b = h("button", "hai-suggestion", s);
|
|
265
|
+
b.type = "button";
|
|
106
266
|
b.onclick = () => { input.value = s; send(); };
|
|
107
267
|
row.append(b);
|
|
108
268
|
}
|
|
@@ -116,13 +276,31 @@ export function mountChat({
|
|
|
116
276
|
// The suggestions follow it until the user engages — a first message, or an
|
|
117
277
|
// answer to something init showed. What the conversation opened with (an
|
|
118
278
|
// init tool's row, a welcome surface) doesn't replace them.
|
|
279
|
+
const engaged = () => chat.state.blocks.some((b) => b.kind === "user" || b.kind === "interaction");
|
|
119
280
|
const render = () => {
|
|
120
281
|
renderTranscript(transcript, chat);
|
|
121
|
-
if (!
|
|
122
|
-
transcript.append(emptyState());
|
|
123
|
-
}
|
|
282
|
+
if (!engaged()) transcript.append(emptyState());
|
|
124
283
|
};
|
|
125
284
|
|
|
285
|
+
// ── status ─────────────────────────────────────────────────────────
|
|
286
|
+
// A live region: it changes only when the status does, so a screen reader
|
|
287
|
+
// hears "Thinking" once a turn, not once per streamed word.
|
|
288
|
+
let shownStatus = null;
|
|
289
|
+
function showStatus(status) {
|
|
290
|
+
if (status === shownStatus) return;
|
|
291
|
+
shownStatus = status;
|
|
292
|
+
statusEl.dataset.status = status;
|
|
293
|
+
statusEl.replaceChildren();
|
|
294
|
+
if (status === "streaming") {
|
|
295
|
+
const dots = h("span", "hai-dots");
|
|
296
|
+
dots.setAttribute("aria-hidden", "true");
|
|
297
|
+
dots.append(h("i"), h("i"), h("i"));
|
|
298
|
+
statusEl.append(dots, "Thinking");
|
|
299
|
+
} else if (status === "awaiting") {
|
|
300
|
+
statusEl.append(h("span", "hai-pulse"), "Waiting on your selection above");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
126
304
|
// ── wiring ─────────────────────────────────────────────────────────
|
|
127
305
|
chat.subscribe((state, event) => {
|
|
128
306
|
// chat.close(): the surfaces are unmounted and the chat lets go of this
|
|
@@ -130,6 +308,8 @@ export function mountChat({
|
|
|
130
308
|
if (event.type === "closed") {
|
|
131
309
|
closeTranscript(transcript);
|
|
132
310
|
shell.remove();
|
|
311
|
+
releaseDrawer();
|
|
312
|
+
if (pinned) unpin(pinned);
|
|
133
313
|
return;
|
|
134
314
|
}
|
|
135
315
|
|
|
@@ -140,52 +320,88 @@ export function mountChat({
|
|
|
140
320
|
render();
|
|
141
321
|
|
|
142
322
|
modelEl.textContent = state.model;
|
|
323
|
+
modelEl.hidden = !state.model;
|
|
324
|
+
// There is something to start over from once the user has engaged, or
|
|
325
|
+
// once the server has a conversation: init may already be asking.
|
|
326
|
+
newBtn.disabled = !engaged() && !state.expired && !state.conversationId;
|
|
143
327
|
input.disabled = state.expired !== null;
|
|
144
328
|
sendBtn.disabled = state.status === "streaming" || state.expired !== null;
|
|
145
|
-
|
|
146
|
-
statusEl.textContent =
|
|
147
|
-
state.status === "streaming" ? "thinking…"
|
|
148
|
-
: state.status === "awaiting" ? "awaiting your selection in the component above"
|
|
149
|
-
: "";
|
|
329
|
+
showStatus(state.expired ? "idle" : state.status);
|
|
150
330
|
input.placeholder =
|
|
151
331
|
state.expired ? "start a new conversation to continue"
|
|
152
332
|
: state.status === "awaiting" ? "pick an option above — or type to override"
|
|
153
333
|
: placeholder;
|
|
154
334
|
|
|
155
|
-
if (inspectorBody)
|
|
335
|
+
if (inspectorBody) {
|
|
336
|
+
renderInspector(inspectorBody, state.context);
|
|
337
|
+
const tokens = state.context.modelTokens;
|
|
338
|
+
ctxCount.textContent = tokens ? `~${tokens.toLocaleString()} tok` : "";
|
|
339
|
+
}
|
|
156
340
|
});
|
|
157
341
|
|
|
158
342
|
sendBtn.onclick = send;
|
|
159
343
|
input.onkeydown = (e) => {
|
|
160
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
344
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
161
345
|
e.preventDefault();
|
|
162
346
|
send();
|
|
163
347
|
}
|
|
164
348
|
};
|
|
165
349
|
|
|
350
|
+
newBtn.disabled = true;
|
|
166
351
|
render();
|
|
352
|
+
// a server without an init tool sends nothing until the first message
|
|
353
|
+
if (inspectorBody) renderInspector(inspectorBody, chat.state.context);
|
|
167
354
|
chat.start();
|
|
168
355
|
return chat;
|
|
169
356
|
}
|
|
170
357
|
|
|
358
|
+
/** A header button: an icon, and a label that narrow screens keep for screen
|
|
359
|
+
* readers only. */
|
|
360
|
+
function button(className, iconName, label) {
|
|
361
|
+
const b = h("button", className);
|
|
362
|
+
b.type = "button";
|
|
363
|
+
b.append(icon(iconName), h("span", "hai-btn-label", label));
|
|
364
|
+
return b;
|
|
365
|
+
}
|
|
366
|
+
|
|
171
367
|
/**
|
|
172
|
-
*
|
|
173
|
-
* argument — keep it on while developing.
|
|
368
|
+
* What the model context debug drawer shows. Makes the dual channel visible,
|
|
369
|
+
* which is the whole argument — keep it on while developing.
|
|
174
370
|
*/
|
|
175
371
|
export function renderInspector(el, { messages = [], modelTokens = 0, uiTokens = 0 } = {}) {
|
|
372
|
+
// following the newest context, as the transcript does, unless scrolled up
|
|
373
|
+
const following = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
176
374
|
el.replaceChildren();
|
|
177
375
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
376
|
+
if (!messages.length && !modelTokens && !uiTokens) {
|
|
377
|
+
el.append(
|
|
378
|
+
h("p", "hai-inspector-empty", "Nothing yet. Every message, tool call and digest the model receives shows up here."),
|
|
379
|
+
);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const total = modelTokens + uiTokens;
|
|
384
|
+
const pct = uiTokens ? Math.round((1 - modelTokens / total) * 100) : 0;
|
|
385
|
+
const stats = h("div", "hai-ctx");
|
|
386
|
+
if (uiTokens) {
|
|
387
|
+
const headline = h("div", "hai-ctx-headline");
|
|
388
|
+
headline.append(h("span", "hai-ctx-pct", `${pct}%`), h("span", null, "kept out of context"));
|
|
389
|
+
stats.append(headline);
|
|
390
|
+
}
|
|
391
|
+
const meter = h("div", "hai-meter");
|
|
392
|
+
meter.setAttribute("aria-hidden", "true");
|
|
393
|
+
const share = h("span", "hai-meter-model");
|
|
394
|
+
share.style.width = `${total ? (modelTokens / total) * 100 : 100}%`;
|
|
395
|
+
meter.append(share);
|
|
396
|
+
stats.append(
|
|
397
|
+
meter,
|
|
398
|
+
stat("hai-key-model", "model context", modelTokens),
|
|
399
|
+
stat("hai-key-ui", "browser payload", uiTokens),
|
|
184
400
|
);
|
|
185
|
-
el.append(
|
|
401
|
+
el.append(stats);
|
|
186
402
|
|
|
187
403
|
for (const m of messages) {
|
|
188
|
-
const box = h("div",
|
|
404
|
+
const box = h("div", `hai-msg hai-msg-${m.role}`);
|
|
189
405
|
box.append(h("div", "hai-role", m.role));
|
|
190
406
|
const body = h("div", "hai-mbody");
|
|
191
407
|
if (typeof m.content === "string") {
|
|
@@ -193,12 +409,36 @@ export function renderInspector(el, { messages = [], modelTokens = 0, uiTokens =
|
|
|
193
409
|
} else {
|
|
194
410
|
for (const b of m.content ?? []) {
|
|
195
411
|
if (b.type === "text" && b.text) body.append(h("div", null, b.text));
|
|
196
|
-
if (b.type === "tool_use")
|
|
197
|
-
|
|
198
|
-
|
|
412
|
+
if (b.type === "tool_use") {
|
|
413
|
+
const call = h("div", "hai-tu");
|
|
414
|
+
call.append(h("span", "hai-kind", `tool_use · ${b.name}`), h("div", null, JSON.stringify(b.input)));
|
|
415
|
+
body.append(call);
|
|
416
|
+
}
|
|
417
|
+
if (b.type === "tool_result") {
|
|
418
|
+
const result = h("div", "hai-tr");
|
|
419
|
+
result.append(h("span", "hai-kind", "tool_result"), h("div", null, plain(b.content)));
|
|
420
|
+
body.append(result);
|
|
421
|
+
}
|
|
199
422
|
}
|
|
200
423
|
}
|
|
201
424
|
box.append(body);
|
|
202
425
|
el.append(box);
|
|
203
426
|
}
|
|
427
|
+
|
|
428
|
+
if (following) el.scrollTop = el.scrollHeight;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function stat(key, label, tokens) {
|
|
432
|
+
const row = h("div", "hai-ctx-stat");
|
|
433
|
+
const name = h("span", "hai-ctx-label");
|
|
434
|
+
name.append(h("i", `hai-key ${key}`), label);
|
|
435
|
+
row.append(name, h("span", "hai-ctx-tok", `~${tokens.toLocaleString()} tok`));
|
|
436
|
+
return row;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** A tool_result's content as text: a string, or an array of content blocks. */
|
|
440
|
+
function plain(content) {
|
|
441
|
+
if (typeof content === "string") return content;
|
|
442
|
+
if (Array.isArray(content)) return content.map((c) => c?.text ?? JSON.stringify(c)).join("\n");
|
|
443
|
+
return JSON.stringify(content);
|
|
204
444
|
}
|