orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1// The browser half. It holds no conversation state of its own beyond the id:
2// the server renders markdown and owns history, so a reload is authoritative
3// and there is nothing here to drift out of step with the database.
4(() => {
5 const $ = (id) => document.getElementById(id);
6 const thread = $("thread"), input = $("input"), form = $("ask");
7 const send = $("send"), stop = $("stop"), welcome = $("welcome");
8 const incognito = $("incognito"), incogFlag = $("incog-flag");
9 const barTitle = $("bar-title"), modelDot = $("model-dot"), footState = $("foot-state");
10 const side = $("side"), convs = $("convs"), scrim = $("scrim");
11
12 // Conversations that finished a turn while the reader was somewhere else.
13 // Declared up here because refreshConversations paints from it and runs
14 // before the live stream is wired.
15 const unread = new Set();
16
17 // The width the stylesheet switches the sidebar to an overlay at. Kept in one
18 // place because the two have to agree: a sidebar that is an overlay in CSS and
19 // open by default in JS covers the conversation on every phone.
20 const NARROW = 832;
21 const isNarrow = () => window.innerWidth <= NARROW;
22
23 // Focusing the box summons the on screen keyboard, so on a phone every
24 // automatic focus costs half the screen and the reader did not ask for it.
25 // A real keyboard is what a fine pointer and hover imply, and pressing a
26 // shortcut proves one either way, so those focus unconditionally.
27 const hasKeyboard = () => matchMedia("(hover: hover) and (pointer: fine)").matches;
28 const refocus = () => { if (hasKeyboard()) input.focus(); };
29
30 function showSide(open) {
31 side.classList.toggle("closed", !open);
32 scrim.hidden = !(open && isNarrow());
33 // A drawer that opens behind an open keyboard is half a drawer.
34 if (open && isNarrow()) document.activeElement?.blur();
35 }
36 const meter = $("meter"), meterNum = $("meter-num"), meterFill = $("meter-fill"),
37 meterCap = $("meter-cap"), tps = $("tps");
38 const tray = $("tray"), picker = $("picker"), drop = $("drop");
39 let ctxSize = Number(meterCap.textContent) || 32768;
40
41 const MAX_FILES = 10, MAX_BYTES = 20 * 1024 * 1024;
42
43 let convID = "";
44 // The key a turn on a brand new conversation runs under, until the server
45 // hands back the real conversation id.
46 let runID = "";
47 let inflight = null;
48 // Files chosen but not sent yet. They stay File objects until the turn goes,
49 // so nothing is uploaded until there is a message to attach them to.
50 let staged = [];
51
52 // ---------------------------------------------------------------- helpers
53
54 const atBottom = () => thread.scrollHeight - thread.scrollTop - thread.clientHeight < 80;
55 function toBottom(force) {
56 if (force || atBottom()) thread.scrollTop = thread.scrollHeight;
57 }
58
59 // The newest exchange is pinned with the question at the top of the viewport
60 // and the answer growing underneath it, which is what every chat does and
61 // what stops a long answer dragging the question off screen.
62 //
63 // It needs a spacer: at the bottom of the thread there is nothing below the
64 // answer to scroll into, so without one the question cannot reach the top.
65 // The spacer shrinks as the answer grows and reaches zero once the exchange
66 // fills the screen on its own.
67 let spacer = null, anchor = null;
68 function makeRoom(node) {
69 anchor = node;
70 if (!spacer) {
71 spacer = document.createElement("div");
72 spacer.className = "spacer";
73 thread.appendChild(spacer);
74 } else {
75 thread.appendChild(spacer);
76 }
77 sizeSpacer();
78 scrollToAnchor(true);
79 }
80 function sizeSpacer() {
81 if (!spacer || !anchor) return;
82 const used = thread.scrollHeight - spacer.offsetHeight - anchor.offsetTop;
83 const room = Math.max(0, thread.clientHeight - used - 24);
84 spacer.style.height = room + "px";
85 }
86 // A few pixels of air above the question, since scrolling it flush to the
87 // container edge tucks its first line under the bar.
88 const PIN_GAP = 14;
89 function scrollToAnchor(smooth) {
90 if (!anchor) return;
91 thread.scrollTo({ top: Math.max(0, anchor.offsetTop - PIN_GAP),
92 behavior: smooth ? "smooth" : "auto" });
93 }
94 function keepPinned() {
95 if (!anchor) return;
96 sizeSpacer();
97 // Follow the text only while the reader is still down here. Scrolling up
98 // to re-read something should not be yanked back.
99 if (atBottom()) return;
100 if (thread.scrollTop < anchor.offsetTop - PIN_GAP - 4) scrollToAnchor(false);
101 }
102
103 function bubble(role) {
104 const node = $("tpl-msg").content.firstElementChild.cloneNode(true);
105 node.classList.add(role === "user" ? "user" : "bot");
106 node.querySelector(".who").textContent = role === "user" ? "You" : "Assistant";
107 thread.appendChild(node);
108 return node;
109 }
110
111 function toolChip(t) {
112 const el = document.createElement("span");
113 el.className = "tool" + (t.ok === false ? " bad" : t.running ? " run" : "");
114 const ms = t.running ? "" : `<span class="ms">${fmtMs(t.ms)}</span>`;
115 // A local snapshot answers as fast as anything and is as old as its file,
116 // so the chip says when it was taken.
117 const age = t.age ? `<span class="age" title="this data was taken ${esc(t.age)}">${esc(t.age)}</span>` : "";
118 el.innerHTML = `<b>${esc(t.name)}</b>${t.args ? " " + esc(t.args) : ""}${age}${ms}`;
119 if (t.err) el.title = t.err;
120 return el;
121 }
122
123 // The record of what a turn did. It is open while the turn runs, so the work
124 // is visible as it happens, and collapses to one line once the answer lands.
125 // Nothing here is decoration: an answer off a local snapshot and one off the
126 // model's memory look identical without it.
127 const STEP_KINDS = {
128 prompt: "prompt", memory: "memory", wikipedia: "wikipedia", model: "model",
129 tool: "tool", gate: "gate", answer: "answer", title: "title", compact: "compact",
130 };
131
132 function workPanel(box) {
133 if (box.dataset.built) return box;
134 box.dataset.built = "1";
135 box.innerHTML =
136 `<button class="work-head" type="button" aria-expanded="true">` +
137 `<span class="chev" aria-hidden="true"></span>` +
138 `<span class="work-k">// WORK</span>` +
139 `<span class="work-sum"></span></button>` +
140 `<ol class="work-steps"></ol>`;
141 const head = box.querySelector(".work-head");
142 head.addEventListener("click", () => {
143 const open = head.getAttribute("aria-expanded") === "true";
144 head.setAttribute("aria-expanded", String(!open));
145 box.querySelector(".work-steps").hidden = open;
146 });
147 return box;
148 }
149
150 // What a row shows without being opened. A collapsed "TOOL web_fetch 613ms"
151 // says nothing about which page, so every kind offers up the one field worth
152 // a glance: what a tool was asked for, and what a model or the gate decided.
153 function stepDetail(s) {
154 const pick = s.kind === "model" || s.kind === "gate" ? s.out : s.in;
155 return (pick || "").replace(/\s+/g, " ").trim();
156 }
157
158 function stepRow(s) {
159 const li = document.createElement("li");
160 li.className = "step" + (s.bad ? " bad" : "");
161 li.dataset.kind = STEP_KINDS[s.kind] || "model";
162 const ms = s.ms ? `<span class="ms">${fmtMs(s.ms)}</span>` : "";
163 const io = (s.in || s.out || s.meta);
164 const detail = stepDetail(s);
165 li.innerHTML =
166 `<button class="step-head" type="button" aria-expanded="false"${io ? "" : " disabled"}>` +
167 `<span class="dot" aria-hidden="true"></span>` +
168 `<span class="k">${esc(s.kind)}</span>` +
169 `<span class="l">${esc(s.label || "")}</span>` +
170 (detail ? `<span class="d" title="${esc(detail)}">${esc(detail)}</span>` : "") +
171 `${ms}</button>` +
172 (io ? `<div class="step-body" hidden>` +
173 (s.meta ? `<div class="step-meta">${esc(s.meta)}</div>` : "") +
174 (s.in ? `<div class="io"><span class="io-k">in</span><pre>${esc(s.in)}</pre></div>` : "") +
175 (s.out ? `<div class="io"><span class="io-k">out</span><pre>${esc(s.out)}</pre></div>` : "") +
176 `</div>` : "");
177 if (io) {
178 const head = li.querySelector(".step-head");
179 head.addEventListener("click", () => {
180 const open = head.getAttribute("aria-expanded") === "true";
181 head.setAttribute("aria-expanded", String(!open));
182 li.querySelector(".step-body").hidden = open;
183 });
184 }
185 return li;
186 }
187
188 function addStep(box, s) {
189 if (!box || !s) return;
190 workPanel(box).hidden = false;
191 box.querySelector(".work-steps").appendChild(stepRow(s));
192 countWork(box);
193 }
194
195 function countWork(box) {
196 const n = box.querySelectorAll(".step").length;
197 box.querySelector(".work-sum").textContent = n + (n === 1 ? " step" : " steps");
198 }
199
200 // Collapsed once the answer is there, since by then the reader wants the
201 // answer and the work is something to open if they care.
202 function finishWork(box, steps) {
203 if (!box) return;
204 if (Array.isArray(steps) && steps.length) {
205 workPanel(box).hidden = false;
206 const list = box.querySelector(".work-steps");
207 if (!list.children.length) list.replaceChildren(...steps.map(stepRow));
208 countWork(box);
209 }
210 if (box.hidden || !box.dataset.built) return;
211 const head = box.querySelector(".work-head");
212 head.setAttribute("aria-expanded", "false");
213 box.querySelector(".work-steps").hidden = true;
214 }
215
216 function sourceChip(s) {
217 const el = document.createElement("a");
218 el.className = "src";
219 el.href = s.url;
220 el.target = "_blank";
221 el.rel = "noopener noreferrer";
222 el.title = s.title || s.site;
223 el.innerHTML = `<b>${s.n}</b>${esc(s.site || s.url)}`;
224 return el;
225 }
226
227 function showSources(box, list) {
228 if (!box || !Array.isArray(list) || !list.length) return;
229 box.hidden = false;
230 box.replaceChildren(...list.map(sourceChip));
231 }
232
233 // A tool that answers instantly has no ms field at all, since the server
234 // omits a zero. Without this the chip reads NaN.
235 function fmtMs(ms) {
236 const n = Number(ms) || 0;
237 return n < 1000 ? n + "ms" : (n / 1000).toFixed(1) + "s";
238 }
239
240 // 1024 and not 1000, because a context window is a power of two and 65536 has
241 // to read as the 64k it was configured as rather than 66k.
242 function kfmt(n) {
243 n = Number(n) || 0;
244 return n < 1024 ? String(n) : (n / 1024).toFixed(n < 10240 ? 1 : 0) + "k";
245 }
246
247 // The meter reports the whole window the model was handed, not the length of
248 // the last message, since that is the number that decides when compaction
249 // has to happen.
250 function showStats(st) {
251 if (!st) return;
252 if (st.ctx) { ctxSize = st.ctx; meterCap.textContent = kfmt(st.ctx); }
253 const used = Number(st.prompt_tokens) || 0;
254 if (used) {
255 const pct = Math.min(100, (used / ctxSize) * 100);
256 meter.hidden = false;
257 meterNum.textContent = kfmt(used);
258 meterFill.style.width = pct.toFixed(1) + "%";
259 meter.classList.toggle("warm", pct >= 55 && pct < 80);
260 meter.classList.toggle("hot", pct >= 80);
261 meter.title = `${used.toLocaleString()} of ${ctxSize.toLocaleString()} tokens used by the last turn`
262 + (pct >= 55 ? ". Older turns get summarised past 55%." : "");
263 }
264 if (st.decode_tps) {
265 tps.hidden = false;
266 tps.innerHTML = `<b>${st.decode_tps}</b> tok/s`;
267 tps.title = `Generated at ${st.decode_tps} tokens a second`
268 + (st.prefill_tps ? `, prompt read at ${st.prefill_tps} a second` : "");
269 }
270 }
271
272 // Both numbers describe the last turn of one thread, so they have to go when
273 // the thread does. Nothing stores them per conversation, so an old one reopens
274 // with an empty bar until it runs a turn.
275 function clearStats() {
276 meter.hidden = true;
277 tps.hidden = true;
278 meterNum.textContent = "0";
279 meterFill.style.width = "0%";
280 meter.classList.remove("warm", "hot");
281 meter.title = "";
282 tps.textContent = "";
283 tps.title = "";
284 }
285
286 function esc(s) {
287 return String(s ?? "").replace(/[&<>"']/g, (c) =>
288 ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
289 }
290
291 function statusLine(text) {
292 let el = thread.querySelector(".status");
293 if (!el) {
294 el = document.createElement("div");
295 el.className = "status";
296 el.innerHTML = `<i class="dot busy"></i><span class="txt"></span><span class="dots"></span>`;
297 thread.insertBefore(el, spacer || null);
298 }
299 el.querySelector(".txt").textContent = text;
300 return el;
301 }
302 const clearStatus = () => thread.querySelectorAll(".status").forEach((e) => e.remove());
303
304 function errorLine(text) {
305 const el = document.createElement("div");
306 el.className = "err";
307 el.textContent = text;
308 thread.appendChild(el);
309 toBottom(true);
310 }
311
312 function busy(on) {
313 send.hidden = on; stop.hidden = !on; input.disabled = on;
314 modelDot.classList.toggle("busy", on);
315 modelDot.classList.toggle("on", !on);
316 if (!on) refocus();
317 }
318
319 // ----------------------------------------------------------- attachments
320
321 // Why a file was refused belongs next to the composer where the refusal
322 // happened, and it goes back to the keybind hint on its own.
323 let footTimer = null;
324 const footRest = footState.textContent;
325 function footNote(text) {
326 footState.textContent = text;
327 clearTimeout(footTimer);
328 footTimer = setTimeout(() => { footState.textContent = footRest; }, 4000);
329 }
330
331 function addFiles(list) {
332 for (const f of list) {
333 if (staged.length >= MAX_FILES) { footNote(`${MAX_FILES} files is the limit.`); break; }
334 if (f.size > MAX_BYTES) { footNote(`${f.name} is over 20 MB.`); continue; }
335 // Same name and size twice is the same file, which is what a second drop
336 // of the same thing produces.
337 if (staged.some((s) => s.name === f.name && s.size === f.size)) continue;
338 staged.push(f);
339 }
340 renderTray();
341 }
342
343 function renderTray() {
344 tray.replaceChildren();
345 tray.hidden = staged.length === 0;
346 staged.forEach((f, i) => {
347 const el = document.createElement("span");
348 el.className = "chip";
349 el.innerHTML = `<span class="nm">${esc(f.name)}</span><span class="sz">${bytes(f.size)}</span>`;
350 const x = document.createElement("button");
351 x.type = "button";
352 x.textContent = "\u2715";
353 x.title = "Remove";
354 x.setAttribute("aria-label", `Remove ${f.name}`);
355 x.addEventListener("click", () => { staged.splice(i, 1); renderTray(); refocus(); });
356 el.appendChild(x);
357 tray.appendChild(el);
358 });
359 sizeSpacer();
360 }
361
362 // A chip on a message that has already been sent. The file is gone by then,
363 // so this only ever reports what happened to it.
364 function fileChip(f) {
365 const el = document.createElement("span");
366 el.className = "chip" + (f.err ? " bad" : "");
367 el.innerHTML = `<span class="nm">${esc(f.name)}</span><span class="sz">${bytes(f.size)}</span>`;
368 if (f.err) el.title = f.err;
369 return el;
370 }
371
372 function bytes(n) {
373 if (n < 1024) return n + " B";
374 if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
375 return (n / (1024 * 1024)).toFixed(1) + " MB";
376 }
377
378 $("attach").addEventListener("click", () => picker.click());
379 picker.addEventListener("change", () => { addFiles(picker.files); picker.value = ""; });
380
381 // dragenter and dragleave fire for every element the pointer crosses, so the
382 // overlay is counted in rather than toggled or it flickers over the thread.
383 let dragDepth = 0;
384 const dragging = (e) => Array.from(e.dataTransfer?.types || []).includes("Files");
385 window.addEventListener("dragenter", (e) => {
386 if (!dragging(e)) return;
387 e.preventDefault();
388 if (++dragDepth === 1) drop.hidden = false;
389 });
390 window.addEventListener("dragover", (e) => { if (dragging(e)) e.preventDefault(); });
391 window.addEventListener("dragleave", (e) => {
392 if (!dragging(e)) return;
393 if (--dragDepth <= 0) { dragDepth = 0; drop.hidden = true; }
394 });
395 window.addEventListener("drop", (e) => {
396 if (!dragging(e)) return;
397 e.preventDefault();
398 dragDepth = 0;
399 drop.hidden = true;
400 addFiles(e.dataTransfer.files);
401 refocus();
402 });
403
404 input.addEventListener("paste", (e) => {
405 const files = Array.from(e.clipboardData?.files || []);
406 if (!files.length) return;
407 e.preventDefault();
408 addFiles(files);
409 });
410
411 // ------------------------------------------------------------ memory
412
413 const memSheet = $("mem-sheet"), memList = $("mem-list"), memMsg = $("mem-msg"),
414 memSaid = $("mem-said"), memSend = $("mem-send"), memCount = $("mem-count");
415
416 function memShow(view) {
417 if (!view) return;
418 memMsg.hidden = !(view.note || view.problem);
419 memMsg.textContent = view.note || view.problem || "";
420 memMsg.classList.toggle("bad", !!view.problem && !view.note);
421
422 const facts = view.facts || [];
423 memCount.textContent = facts.length
424 ? `${facts.length} fact${facts.length === 1 ? "" : "s"}`
425 : "";
426 memList.replaceChildren();
427 if (!facts.length) {
428 memList.innerHTML = `<p class="mem-empty">Nothing yet. It picks things up as you talk, and you can tell it something above.</p>`;
429 return;
430 }
431 for (const f of facts) {
432 const el = document.createElement("div");
433 el.className = "fact";
434 el.innerHTML = `<span class="ft"></span>` +
435 `<span class="fu">${f.used ? `used ${f.used}\u00d7` : "unused"}</span>`;
436 el.querySelector(".ft").textContent = f.fact;
437 const x = document.createElement("button");
438 x.type = "button";
439 x.className = "fx";
440 x.textContent = "\u2715";
441 x.title = "Forget this";
442 x.setAttribute("aria-label", "Forget this");
443 x.addEventListener("click", async () => {
444 el.classList.add("going");
445 memShow(await memCall("DELETE", "/api/memory/" + f.id));
446 });
447 el.appendChild(x);
448 memList.appendChild(el);
449 }
450 }
451
452 async function memCall(method, url, body) {
453 try {
454 const r = await fetch(url, {
455 method,
456 headers: body ? { "content-type": "application/json" } : undefined,
457 body: body ? JSON.stringify(body) : undefined,
458 });
459 if (!r.ok) return { problem: "that did not go through (" + r.status + ")" };
460 return await r.json();
461 } catch (e) {
462 return { problem: e.message || String(e) };
463 }
464 }
465
466 async function memToggle(on) {
467 const show = on ?? memSheet.hidden;
468 memSheet.hidden = !show;
469 if (show) {
470 memShow({ facts: [] });
471 memShow(await memCall("GET", "/api/memory"));
472 if (hasKeyboard()) memSaid.focus();
473 } else {
474 refocus();
475 }
476 }
477
478 $("mem-open").addEventListener("click", () => memToggle(true));
479 $("mem-close").addEventListener("click", () => memToggle(false));
480 memSheet.addEventListener("click", (e) => { if (e.target === memSheet) memToggle(false); });
481
482 $("mem-form").addEventListener("submit", async (e) => {
483 e.preventDefault();
484 const said = memSaid.value.trim();
485 if (!said) return;
486 // A round trip here is a model call, so the wait is real and the button
487 // has to say so rather than looking like nothing happened.
488 memSend.disabled = true;
489 memSend.textContent = "thinking";
490 memMsg.hidden = false;
491 memMsg.classList.remove("bad");
492 memMsg.textContent = "Working out what should change...";
493 const view = await memCall("POST", "/api/memory/teach", { said });
494 memSend.disabled = false;
495 memSend.textContent = "Tell it";
496 if (!view.problem) memSaid.value = "";
497 memShow(view);
498 if (hasKeyboard()) memSaid.focus();
499 });
500
501 memSaid.addEventListener("keydown", (e) => {
502 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("mem-form").requestSubmit(); }
503 });
504
505 $("mem-forget").addEventListener("click", async () => {
506 if (!confirm("Forget every one of these? This cannot be undone.")) return;
507 memShow(await memCall("DELETE", "/api/memory"));
508 });
509
510 // ---------------------------------------------------------------- sending
511
512 // The stream is read the same way whether it came from starting a turn or
513 // from attaching to one already running, so both go through here.
514 async function consume(resp, ui) {
515 const reader = resp.body.getReader();
516 const dec = new TextDecoder();
517 let buf = "";
518 for (;;) {
519 const { done, value } = await reader.read();
520 if (done) break;
521 buf += dec.decode(value, { stream: true });
522 // Server sent events are separated by a blank line, and a chunk can
523 // split one in half, so anything after the last separator is kept.
524 const parts = buf.split("\n\n");
525 buf = parts.pop();
526 for (const part of parts) {
527 const line = part.split("\n").find((l) => l.startsWith("data:"));
528 if (!line) continue;
529 let ev;
530 try { ev = JSON.parse(line.slice(5).trim()); } catch { continue; }
531 handle(ev, ui);
532 }
533 }
534 }
535
536 async function ask(text) {
537 // A turn made only of files is a real question, so an empty box is only
538 // empty when nothing is attached either.
539 if ((!text.trim() && !staged.length) || inflight) return;
540 welcome?.remove();
541 const sending = staged;
542 staged = [];
543 renderTray();
544
545 const mine = bubble("user");
546 mine.querySelector(".body").textContent = text;
547 if (!text.trim()) mine.querySelector(".body").remove();
548 if (sending.length) {
549 const fb = mine.querySelector(".files");
550 fb.hidden = false;
551 fb.replaceChildren(...sending.map((f) => fileChip({ name: f.name, size: f.size })));
552 }
553
554 const reply = bubble("bot");
555 const body = reply.querySelector(".body");
556 const toolbar = reply.querySelector(".tools");
557 const workbox = reply.querySelector(".work");
558 const srcbox = reply.querySelector(".sources");
559 const wdgbox = reply.querySelector(".widgets");
560 body.classList.add("typing");
561 makeRoom(mine);
562
563 // The body is built from finished blocks plus one trailing paragraph of
564 // plain text. Nothing is re-rendered at the end, so there is no reflow to
565 // read through.
566 const blocks = document.createElement("div");
567 const tail = document.createElement("p");
568 tail.className = "tail";
569 body.append(blocks, tail);
570
571 const ctl = new AbortController();
572 inflight = ctl;
573 busy(true);
574 statusLine("thinking");
575
576 try {
577 const convFor = incognito.checked ? "" : convID;
578 // A conversation with no id yet still needs a key the run can be found
579 // under, or a turn started on a new chat is lost the moment the tab goes.
580 const runFor = convFor || (runID = crypto.randomUUID());
581 // A form rather than JSON once there are files, and JSON when there are
582 // none so the ordinary turn does not pay for multipart framing.
583 let init;
584 if (sending.length) {
585 const fd = new FormData();
586 fd.append("message", text);
587 fd.append("conversation_id", String(convFor));
588 fd.append("run_id", runFor);
589 fd.append("incognito", String(incognito.checked));
590 for (const f of sending) fd.append("files", f, f.name);
591 init = { method: "POST", body: fd, signal: ctl.signal };
592 } else {
593 init = {
594 method: "POST",
595 headers: { "content-type": "application/json" },
596 body: JSON.stringify({
597 message: text,
598 conversation_id: convFor,
599 run_id: runFor,
600 incognito: incognito.checked,
601 }),
602 signal: ctl.signal,
603 };
604 }
605 const resp = await fetch("/api/send", init);
606 if (!resp.ok || !resp.body) throw new Error("the server refused that (" + resp.status + ")");
607
608 await consume(resp, { body, toolbar, srcbox, blocks, tail, widgets: wdgbox,
609 work: workbox, files: mine.querySelector(".files") });
610 } catch (e) {
611 if (e.name !== "AbortError") errorLine(e.message || String(e));
612 } finally {
613 clearStatus();
614 body.classList.remove("typing");
615 if (tail.isConnected && !tail.textContent.trim()) tail.remove();
616 if (!body.textContent.trim()) reply.remove();
617 inflight = null;
618 busy(false);
619 sizeSpacer();
620 // A turn is when search finds out it has been rate limited, so the bar
621 // learns about it here rather than on the next reload.
622 refreshStatus();
623 }
624 }
625
626 function handle(ev, ui) {
627 switch (ev.kind) {
628 case "status":
629 statusLine(ev.text);
630 keepPinned();
631 break;
632 case "tool": {
633 ui.toolbar.hidden = false;
634 const chip = toolChip({ name: ev.tool, args: ev.args, running: true });
635 chip.dataset.pending = ev.tool;
636 ui.toolbar.appendChild(chip);
637 statusLine(ev.tool.replace(/_/g, " "));
638 keepPinned();
639 break;
640 }
641 case "tool_done": {
642 const pending = ui.toolbar.querySelector(`[data-pending="${CSS.escape(ev.tool)}"]`);
643 if (pending) {
644 pending.removeAttribute("data-pending");
645 pending.classList.remove("run");
646 if (!ev.ok) pending.classList.add("bad");
647 pending.insertAdjacentHTML("beforeend", `<span class="ms">${fmtMs(ev.ms)}</span>`);
648 }
649 break;
650 }
651 case "widget":
652 // Straight in, rather than waiting for the answer, so the chart is on
653 // screen while the prose about it is still being written.
654 if (ev.widget) window.Widgets.add(ui.widgets, ev.widget);
655 keepPinned();
656 break;
657 case "block":
658 clearStatus();
659 // Append rather than replace, so nothing already on screen moves.
660 ui.blocks.insertAdjacentHTML("beforeend", ev.html);
661 ui.tail.textContent = "";
662 keepPinned();
663 break;
664 case "tail":
665 clearStatus();
666 ui.tail.textContent = ev.text || "";
667 keepPinned();
668 break;
669 case "step":
670 addStep(ui.work, ev.step);
671 break;
672 case "error":
673 clearStatus();
674 errorLine(ev.text);
675 break;
676 case "done":
677 clearStatus();
678 showStats(ev.stats);
679 ui.tail.remove();
680 if (ev.conversation_id) {
681 const isNew = !convID;
682 convID = ev.conversation_id;
683 if (isNew) {
684 // The address has to catch up with the conversation that now
685 // exists, or a reload lands back on an empty page.
686 history.replaceState({ id: convID }, "", "/c/" + convID);
687 refreshConversations();
688 }
689 }
690 if (ev.title) barTitle.textContent = ev.title;
691 // The streamed blocks and the stored answer can differ, since some of
692 // the repair only makes sense once the whole thing has arrived. Swap
693 // only when they actually differ, because replacing identical html
694 // still makes the message jump.
695 if (ev.html && ui.blocks) {
696 const now = ui.blocks.textContent.replace(/\s+/g, " ").trim();
697 const settled = document.createElement("div");
698 settled.innerHTML = ev.html;
699 if (settled.textContent.replace(/\s+/g, " ").trim() !== now) {
700 ui.blocks.replaceChildren(...settled.childNodes);
701 }
702 }
703 if (Array.isArray(ev.tools) && ev.tools.length) {
704 ui.toolbar.hidden = false;
705 ui.toolbar.replaceChildren(...ev.tools.map(toolChip));
706 }
707 finishWork(ui.work, ev.steps);
708 showSources(ui.srcbox, ev.sources);
709 if (Array.isArray(ev.files) && ev.files.length && ui.files) {
710 ui.files.hidden = false;
711 ui.files.replaceChildren(...ev.files.map(fileChip));
712 }
713 keepPinned();
714 break;
715 }
716 }
717
718 // ---------------------------------------------------------------- history
719
720 async function refreshConversations() {
721 try {
722 const r = await fetch("/api/conversations");
723 const d = await r.json();
724 convs.replaceChildren();
725 if (!d.conversations || !d.conversations.length) {
726 convs.innerHTML = `<p class="empty">Nothing yet. Ask something.</p>`;
727 } else {
728 for (const c of d.conversations) {
729 const a = document.createElement("a");
730 a.className = "conv" + (c.id === convID ? " active" : "");
731 a.href = "/c/" + c.id;
732 a.dataset.id = c.id;
733 a.innerHTML = `<span class="conv-title">${esc(c.title)}</span>` +
734 `<span class="conv-when">${esc(when(c.updated))}</span>` +
735 `<button class="conv-del" data-del="${c.id}" title="Delete" aria-label="Delete conversation">✕</button>`;
736 convs.appendChild(a);
737 }
738 }
739 if (!filter.hidden) applyFilter();
740 paintUnread();
741 const s = await (await fetch("/api/status")).json();
742 $("stat-convs").textContent = s.conversations;
743 $("stat-msgs").textContent = s.messages;
744 if (s.ctx) { ctxSize = s.ctx; meterCap.textContent = kfmt(s.ctx); }
745 } catch { /* the list is a convenience, not the conversation */ }
746 }
747
748 function when(iso) {
749 const d = (Date.now() - new Date(iso)) / 1000;
750 if (d < 60) return "just now";
751 if (d < 3600) return Math.floor(d / 60) + "m ago";
752 if (d < 86400) return Math.floor(d / 3600) + "h ago";
753 return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short" });
754 }
755
756 async function openConversation(id) {
757 const r = await fetch("/api/conversation/" + id);
758 if (!r.ok) return;
759 const d = await r.json();
760 convID = id;
761 unread.delete(id);
762 thread.replaceChildren();
763 spacer = null; anchor = null;
764 barTitle.textContent = d.title || "Conversation";
765 clearStats();
766 for (const m of d.messages) {
767 const node = bubble(m.role === "user" ? "user" : "bot");
768 const body = node.querySelector(".body");
769 if (m.role === "user") body.textContent = m.text;
770 else body.innerHTML = m.html || esc(m.text);
771 if (m.role === "user" && !m.text) body.remove();
772 if (m.files && m.files.length) {
773 const fb = node.querySelector(".files");
774 fb.hidden = false;
775 fb.replaceChildren(...m.files.map(fileChip));
776 }
777 if (m.tools && m.tools.length) {
778 const tb = node.querySelector(".tools");
779 tb.hidden = false;
780 tb.replaceChildren(...m.tools.map(toolChip));
781 }
782 window.Widgets.render(node.querySelector(".widgets"), m.widgets);
783 if (m.steps && m.steps.length) finishWork(node.querySelector(".work"), m.steps);
784 showSources(node.querySelector(".sources"), m.sources);
785 }
786 document.querySelectorAll(".conv").forEach((el) =>
787 el.classList.toggle("active", el.dataset.id === id));
788 history.pushState({ id }, "", "/c/" + id);
789 toBottom(true);
790 if (isNarrow()) showSide(false);
791 // A turn is still writing into this one. Put an empty reply back on screen
792 // and follow it, which is what makes closing the tab mid answer survivable.
793 if (d.running) follow(id);
794 }
795
796 // follow attaches to a turn already running and renders the rest of it. The
797 // server replays what the turn has produced so far, so this draws the whole
798 // answer and not only the part that arrives after we asked.
799 async function follow(id) {
800 if (inflight) return;
801 const reply = bubble("bot");
802 const body = reply.querySelector(".body");
803 const blocks = document.createElement("div");
804 const tail = document.createElement("p");
805 tail.className = "tail";
806 body.append(blocks, tail);
807 body.classList.add("typing");
808
809 const ctl = new AbortController();
810 inflight = ctl;
811 busy(true);
812 statusLine("still working");
813 try {
814 const resp = await fetch("/api/attach/" + id, { signal: ctl.signal });
815 if (resp.status === 404) { reply.remove(); return; }
816 if (!resp.ok || !resp.body) throw new Error("could not follow that turn");
817 await consume(resp, {
818 body, blocks, tail,
819 toolbar: reply.querySelector(".tools"),
820 srcbox: reply.querySelector(".sources"),
821 widgets: reply.querySelector(".widgets"),
822 work: reply.querySelector(".work"),
823 files: null,
824 });
825 } catch (e) {
826 if (e.name !== "AbortError") errorLine(e.message || String(e));
827 } finally {
828 clearStatus();
829 body.classList.remove("typing");
830 if (tail.isConnected && !tail.textContent.trim()) tail.remove();
831 if (!body.textContent.trim()) reply.remove();
832 inflight = null;
833 busy(false);
834 sizeSpacer();
835 }
836 }
837
838 // ---------------------------------------------------------------- wiring
839
840 form.addEventListener("submit", (e) => {
841 e.preventDefault();
842 const t = input.value;
843 input.value = "";
844 input.style.height = "auto";
845 ask(t);
846 });
847
848 input.addEventListener("keydown", (e) => {
849 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
850 });
851 input.addEventListener("input", () => {
852 input.style.height = "auto";
853 input.style.height = Math.min(input.scrollHeight, 176) + "px";
854 });
855
856 // Aborting the fetch only drops this reader now, since the turn runs on the
857 // server without one. Stopping has to say so.
858 async function stopTurn() {
859 const key = convID || runID;
860 inflight?.abort();
861 if (key) {
862 try { await fetch("/api/stop/" + key, { method: "POST" }); } catch {}
863 }
864 }
865 stop.addEventListener("click", stopTurn);
866
867 document.querySelectorAll(".chip").forEach((c) =>
868 c.addEventListener("click", () => ask(c.dataset.ask)));
869
870 $("new-chat").addEventListener("click", () => {
871 convID = "";
872 thread.replaceChildren();
873 spacer = null; anchor = null;
874 barTitle.textContent = "New conversation";
875 clearStats();
876 staged = [];
877 renderTray();
878 history.pushState({}, "", "/");
879 document.querySelectorAll(".conv").forEach((el) => el.classList.remove("active"));
880 refocus();
881 });
882
883 convs.addEventListener("click", async (e) => {
884 const del = e.target.closest("[data-del]");
885 if (del) {
886 e.preventDefault(); e.stopPropagation();
887 await fetch("/api/conversation/" + del.dataset.del, { method: "DELETE" });
888 if (del.dataset.del === convID) $("new-chat").click();
889 refreshConversations();
890 return;
891 }
892 const a = e.target.closest(".conv");
893 if (a) { e.preventDefault(); openConversation(a.dataset.id); }
894 });
895
896 $("wipe").addEventListener("click", async () => {
897 if (!confirm("Delete every saved conversation? This cannot be undone.")) return;
898 await fetch("/api/conversations", { method: "DELETE" });
899 $("new-chat").click();
900 refreshConversations();
901 });
902
903 incognito.addEventListener("change", () => {
904 incogFlag.hidden = !incognito.checked;
905 footState.textContent = incognito.checked
906 ? "Incognito. This conversation is not being written down."
907 : "Enter to send, shift and enter for a new line.";
908 if (incognito.checked) $("new-chat").click();
909 });
910
911 $("side-close").addEventListener("click", () => showSide(false));
912 $("side-open").addEventListener("click", () => showSide(true));
913 scrim.addEventListener("click", () => showSide(false));
914
915 // ------------------------------------------------------------- keyboard
916 //
917 // The bindings other chat apps use, so muscle memory carries over. Chrome
918 // keeps Ctrl+Shift+I for its developer tools and a page cannot always take
919 // it back, which is why incognito answers to Ctrl+Shift+P as well.
920 const sheet = $("keys-sheet"), filter = $("filter");
921
922 function toggleSheet(on) {
923 sheet.hidden = on === undefined ? !sheet.hidden : !on;
924 if (!sheet.hidden) $("keys-close").focus(); else refocus();
925 }
926
927 function toggleFilter(on) {
928 filter.hidden = on === undefined ? !filter.hidden : !on;
929 showSide(true);
930 if (!filter.hidden) { filter.focus(); filter.select(); }
931 else { filter.value = ""; applyFilter(); }
932 }
933
934 function applyFilter() {
935 const q = filter.value.trim().toLowerCase();
936 let shown = 0;
937 document.querySelectorAll(".conv").forEach((el) => {
938 const hit = !q || el.textContent.toLowerCase().includes(q);
939 el.style.display = hit ? "" : "none";
940 if (hit) shown++;
941 });
942 const none = convs.querySelector(".no-hits");
943 if (!shown && q) {
944 if (!none) {
945 const p = document.createElement("p");
946 p.className = "empty no-hits";
947 p.textContent = "Nothing matches.";
948 convs.appendChild(p);
949 }
950 } else if (none) none.remove();
951 }
952 filter.addEventListener("input", applyFilter);
953 filter.addEventListener("keydown", (e) => {
954 if (e.key === "Escape") { e.preventDefault(); toggleFilter(false); input.focus(); }
955 });
956
957 document.addEventListener("keydown", (e) => {
958 const mod = e.ctrlKey || e.metaKey;
959 const key = (e.key || "").toLowerCase();
960
961 if (key === "escape") {
962 if (!memSheet.hidden) { e.preventDefault(); memToggle(false); return; }
963 if (isNarrow() && !side.classList.contains("closed")) { e.preventDefault(); showSide(false); return; }
964 if (!sheet.hidden) { e.preventDefault(); toggleSheet(false); return; }
965 if (e.shiftKey) { e.preventDefault(); input.focus(); return; }
966 if (inflight) { e.preventDefault(); stopTurn(); return; }
967 return;
968 }
969 if (!mod) return;
970
971 if (e.shiftKey && key === "o") { e.preventDefault(); $("new-chat").click(); input.focus(); return; }
972 if (e.shiftKey && (key === "i" || key === "p")) {
973 e.preventDefault();
974 incognito.checked = !incognito.checked;
975 incognito.dispatchEvent(new Event("change"));
976 return;
977 }
978 if (e.shiftKey && key === "s") { e.preventDefault(); showSide(side.classList.contains("closed")); return; }
979 if (e.shiftKey && (key === "backspace" || key === "delete")) {
980 e.preventDefault();
981 if (!convID) return;
982 if (!confirm("Delete this conversation?")) return;
983 fetch("/api/conversation/" + convID, { method: "DELETE" })
984 .then(() => { $("new-chat").click(); refreshConversations(); });
985 return;
986 }
987 if (!e.shiftKey && key === "k") { e.preventDefault(); toggleFilter(); return; }
988 if (!e.shiftKey && key === "/") { e.preventDefault(); toggleSheet(); return; }
989 if (e.shiftKey && key === "m") { e.preventDefault(); memToggle(); return; }
990 });
991
992 $("keys-open").addEventListener("click", () => toggleSheet(true));
993 $("keys-close").addEventListener("click", () => toggleSheet(false));
994 sheet.addEventListener("click", (e) => { if (e.target === sheet) toggleSheet(false); });
995
996 window.addEventListener("resize", () => {
997 sizeSpacer();
998 // Rotating a phone or widening a window must not leave a scrim over a
999 // sidebar that is now part of the layout again.
1000 if (!isNarrow()) scrim.hidden = true;
1001 else scrim.hidden = side.classList.contains("closed");
1002 });
1003
1004 // ------------------------------------------------------------ live meta
1005 //
1006 // A turn already outlives the tab that started it and nothing told the other
1007 // tabs. So a question asked on the desktop never reached the phone without a
1008 // reload, and switching conversations mid answer meant refreshing to find out
1009 // it had finished. This stream carries which conversation changed and never
1010 // what was said, so a tab knows what to go and read.
1011 function markUnread(id) {
1012 unread.add(id);
1013 paintUnread();
1014 }
1015
1016 function paintUnread() {
1017 document.querySelectorAll(".conv").forEach((el) =>
1018 el.classList.toggle("done", unread.has(el.dataset.id)));
1019 const n = unread.size;
1020 $("side-open").classList.toggle("dot-on", n > 0);
1021 }
1022
1023 function liveMeta() {
1024 const es = new EventSource("/api/events");
1025 es.addEventListener("message", async (m) => {
1026 let ev;
1027 try { ev = JSON.parse(m.data); } catch { return; }
1028 if (ev.kind === "changed") { refreshConversations().then(paintUnread); return; }
1029
1030 if (ev.kind === "started") {
1031 // Another tab, or another device, asked this conversation something.
1032 // Follow it if it is the one on screen and nothing here is streaming.
1033 if (ev.conversation_id === convID && !inflight) follow(convID);
1034 return;
1035 }
1036 if (ev.kind === "finished") {
1037 if (ev.conversation_id === convID) {
1038 // Only when this tab was not the one writing it. The turn we ran has
1039 // already drawn itself and re-opening would replay the whole thread.
1040 if (!inflight) await openConversation(convID);
1041 } else {
1042 markUnread(ev.conversation_id);
1043 }
1044 refreshConversations().then(paintUnread);
1045 }
1046 });
1047 // EventSource reconnects on its own, and a reconnect after the server
1048 // restarted is the case that matters, so the list is re-read on open.
1049 es.addEventListener("open", () => refreshConversations().then(paintUnread));
1050 }
1051 liveMeta();
1052
1053 window.addEventListener("popstate", () => {
1054 const m = location.pathname.match(/^\/c\/([\w-]+)/);
1055 if (m) openConversation(m[1]); else $("new-chat").click();
1056 });
1057
1058 // Is the model server up, and can anything be looked up? Asking /api/status
1059 // never wakes the weights. It runs again after every turn, since a turn is
1060 // when search discovers it has been rate limited.
1061 const searchDown = $("search-down");
1062 async function refreshStatus() {
1063 try {
1064 const s = await (await fetch("/api/status")).json();
1065 if (s.ctx) { ctxSize = s.ctx; meterCap.textContent = kfmt(s.ctx); }
1066 modelDot.classList.toggle("on", !!s.up);
1067 modelDot.title = s.up ? "The model answering" : "The model server is not answering";
1068 const left = s.search_left || {};
1069 // Down means a host refused us. Spent means we stopped ourselves. They
1070 // read the same to a reader mid conversation, so the flag covers both and
1071 // the tooltip says which.
1072 const spent = left.day <= 0 || left.hour <= 0 || left.minute <= 0;
1073 searchDown.hidden = !(s.search_down || spent);
1074 if (s.search_down) {
1075 searchDown.textContent = "NO SEARCH";
1076 searchDown.title = "Search refused this address and is being left alone for another " +
1077 (s.search_back_in || "while") + ". Nothing can be looked up until then.";
1078 } else if (spent) {
1079 searchDown.textContent = "SEARCH PAUSED";
1080 searchDown.title = "The search budget is spent for now, so searching is paused " +
1081 "rather than pushed. It frees up on its own.";
1082 } else if (left.day !== undefined) {
1083 searchDown.title = "";
1084 // Not a warning until it is low, just something the bar can answer.
1085 $("model-dot").title = (s.up ? "The model answering" : "The model server is not answering") +
1086 "\nSearches left today: " + left.day;
1087 }
1088 } catch { /* leave the bar as it was */ }
1089 }
1090 refreshStatus();
1091
1092 // ---------------------------------------------------------------- viewport
1093 //
1094 // An open keyboard shrinks the visual viewport and leaves the layout one
1095 // alone, so a shell sized in dvh keeps its composer under the keyboard. The
1096 // meta tag handles this where interactive-widget is supported and this
1097 // covers the rest, iOS included.
1098 const vv = window.visualViewport;
1099 if (vv) {
1100 const fit = () => {
1101 document.documentElement.style.setProperty("--app-h", vv.height + "px");
1102 // Safari scrolls the window to reveal a focused field even when there is
1103 // nothing to scroll, which leaves the page sitting above its own top.
1104 if (window.scrollY !== 0) window.scrollTo(0, 0);
1105 sizeSpacer();
1106 };
1107 vv.addEventListener("resize", fit);
1108 vv.addEventListener("scroll", fit);
1109 fit();
1110 }
1111
1112 // Dragging a drawer shut is how a phone expects to close one, and the button
1113 // that opens it is at the far end of the screen from a thumb.
1114 let swipe = null;
1115 side.addEventListener("touchstart", (e) => {
1116 if (!isNarrow() || side.classList.contains("closed") || e.touches.length !== 1) return;
1117 swipe = { x: e.touches[0].clientX, y: e.touches[0].clientY, live: false };
1118 }, { passive: true });
1119 side.addEventListener("touchmove", (e) => {
1120 if (!swipe) return;
1121 const dx = e.touches[0].clientX - swipe.x, dy = e.touches[0].clientY - swipe.y;
1122 // Only once the gesture is clearly sideways, or every scroll of the list
1123 // drags the drawer with it.
1124 if (!swipe.live) {
1125 if (Math.abs(dx) < 12 || Math.abs(dx) < Math.abs(dy)) return;
1126 swipe.live = true;
1127 side.style.transition = "none";
1128 }
1129 side.style.transform = "translateX(" + Math.min(0, dx) + "px)";
1130 }, { passive: true });
1131 const swipeEnd = (e) => {
1132 if (!swipe) return;
1133 const moved = swipe.live ? e.changedTouches[0].clientX - swipe.x : 0;
1134 swipe = null;
1135 side.style.transition = "";
1136 side.style.transform = "";
1137 if (moved < -side.offsetWidth / 3) showSide(false);
1138 };
1139 side.addEventListener("touchend", swipeEnd, { passive: true });
1140 side.addEventListener("touchcancel", swipeEnd, { passive: true });
1141
1142 // Before anything else paints. The stylesheet makes the sidebar an overlay
1143 // under 52em, and open is the wrong default for an overlay: it covers the
1144 // conversation on every phone.
1145 if (isNarrow()) showSide(false);
1146
1147 const first = location.pathname.match(/^\/c\/([\w-]+)/);
1148 if (first) openConversation(first[1]);
1149 refocus();
1150})();