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