repos
/ orchard main

orchard

mirror

Every 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

44.1 KB · 1151 lines · JavaScript Raw History
   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      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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">&#10005;</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})();