4ee7d3db7d
Filling the composer and waiting for Enter made the card a form to review rather than a thing to press. One click, one reply. That changes what a prompt has to be. The built-ins ended mid-sentence -- "My plan: " -- because nothing was sent until the person finished the thought; sent cold they are a model guessing at material nobody gave it. All three are rewritten to ask for what they need, so the first reply is the right question instead. There is a test that they end as complete sentences, since the failure is silent and only visible in the answer. requestSubmit, not submit: it fires the submit event, which is what htmx listens for. Same call the Enter key already makes. Version bumped because app.js is what changed, and the service worker caches it -- without the bump the first load after this would still only fill the box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
467 lines
17 KiB
JavaScript
467 lines
17 KiB
JavaScript
/*
|
|
Client-side behaviour.
|
|
|
|
Everything here is progressive: the application is server-rendered and works
|
|
without this file, apart from the streaming reply, which is htmx's SSE
|
|
extension rather than anything hand-written below.
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
var THEME_KEY = "lembas-theme";
|
|
var THEMES = ["moria", "shire"];
|
|
|
|
/* --- Theme -------------------------------------------------------------
|
|
Stored locally so the choice applies instantly and survives being signed
|
|
out, and mirrored to the server so it follows the user to another device.
|
|
The server call is best-effort: a failure must not undo the local switch. */
|
|
function currentTheme() {
|
|
return document.documentElement.dataset.theme || THEMES[0];
|
|
}
|
|
|
|
function applyTheme(name) {
|
|
if (THEMES.indexOf(name) === -1) return;
|
|
document.documentElement.dataset.theme = name;
|
|
try {
|
|
localStorage.setItem(THEME_KEY, name);
|
|
} catch (e) { /* private mode */ }
|
|
|
|
/* Installed, the browser's own chrome is the application's chrome, so it
|
|
has to follow the theme too. Read from the stylesheet rather than
|
|
repeating the hex here: tokens.css is the one place colours live. */
|
|
var meta = document.querySelector('meta[name="theme-color"]');
|
|
if (meta) {
|
|
var bg = getComputedStyle(document.documentElement)
|
|
.getPropertyValue("--bg").trim();
|
|
if (bg) meta.setAttribute("content", bg);
|
|
}
|
|
|
|
document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
|
|
el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)"
|
|
: "Switch to Moria (dark)");
|
|
});
|
|
|
|
if (document.body.dataset.authenticated === "true") {
|
|
fetch("/api/preferences/theme", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ theme: name })
|
|
}).catch(function () { /* preference is already applied locally */ });
|
|
}
|
|
}
|
|
|
|
function toggleTheme() {
|
|
applyTheme(currentTheme() === "moria" ? "shire" : "moria");
|
|
}
|
|
|
|
/* --- Textarea autosize -------------------------------------------------
|
|
Grows the composer with its content up to a cap, after which it scrolls. */
|
|
function autosize(el) {
|
|
if (!el) return;
|
|
var max = parseInt(el.dataset.maxHeight || "320", 10);
|
|
el.style.height = "auto";
|
|
el.style.height = Math.min(el.scrollHeight, max) + "px";
|
|
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
|
|
}
|
|
|
|
/* --- Copy --------------------------------------------------------------
|
|
Falls back to a hidden textarea because navigator.clipboard is unavailable
|
|
on pages served over plain http, which self-hosted installs often are. */
|
|
function copyText(text, trigger) {
|
|
function done() {
|
|
if (!trigger) return;
|
|
var original = trigger.getAttribute("aria-label");
|
|
trigger.classList.add("is-copied");
|
|
trigger.setAttribute("aria-label", "Copied");
|
|
setTimeout(function () {
|
|
trigger.classList.remove("is-copied");
|
|
if (original) trigger.setAttribute("aria-label", original);
|
|
}, 1400);
|
|
}
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(text).then(done).catch(function () {});
|
|
return;
|
|
}
|
|
var scratch = document.createElement("textarea");
|
|
scratch.value = text;
|
|
scratch.setAttribute("readonly", "");
|
|
scratch.style.position = "fixed";
|
|
scratch.style.opacity = "0";
|
|
document.body.appendChild(scratch);
|
|
scratch.select();
|
|
try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ }
|
|
document.body.removeChild(scratch);
|
|
}
|
|
|
|
/* --- Thread scrolling --------------------------------------------------
|
|
Only auto-scrolls when the reader is already near the bottom, so scrolling
|
|
up to re-read something is not yanked away by an incoming token. */
|
|
var STICK_THRESHOLD = 120;
|
|
|
|
function isNearBottom(el) {
|
|
return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
|
}
|
|
|
|
function scrollThread(force) {
|
|
var thread = document.getElementById("thread-scroll");
|
|
if (!thread) return;
|
|
if (force || isNearBottom(thread)) {
|
|
thread.scrollTop = thread.scrollHeight;
|
|
}
|
|
}
|
|
|
|
/* --- Attachments -------------------------------------------------------
|
|
Files are uploaded one at a time as soon as they are chosen, dropped or
|
|
pasted, rather than all at once when the message is sent. The chip (or the
|
|
rejection) then appears immediately, and a large file cannot make the send
|
|
button appear to hang. */
|
|
function uploadFiles(fileList) {
|
|
var input = document.getElementById("file-input");
|
|
var target = document.getElementById("attachments");
|
|
if (!input || !target || !fileList || !fileList.length) return;
|
|
|
|
var url = input.dataset.uploadUrl;
|
|
|
|
Array.prototype.forEach.call(fileList, function (file) {
|
|
var body = new FormData();
|
|
body.append("file", file, file.name);
|
|
|
|
fetch(url, { method: "POST", body: body, credentials: "same-origin" })
|
|
.then(function (response) { return response.text(); })
|
|
.then(function (html) {
|
|
target.insertAdjacentHTML("beforeend", html);
|
|
// The chip's remove button is htmx-driven, so the new markup has to
|
|
// be announced or its attributes are inert.
|
|
if (window.htmx) window.htmx.process(target.lastElementChild);
|
|
})
|
|
.catch(function () {
|
|
target.insertAdjacentHTML(
|
|
"beforeend",
|
|
'<div class="chip chip--error"><span class="chip__body">' +
|
|
'<span class="chip__name"></span>' +
|
|
'<span class="chip__warning">Upload failed.</span></span></div>'
|
|
);
|
|
// Set as text, never as HTML: the filename comes from the user.
|
|
target.lastElementChild.querySelector(".chip__name").textContent = file.name;
|
|
});
|
|
});
|
|
}
|
|
|
|
/* --- Attaching something that is not a file ----------------------------
|
|
The composer's menu offers four things; two of them are the file picker
|
|
with a different filter, and two need a round trip. Both of those post a
|
|
form and get a chip back, exactly like an upload, so the composer does not
|
|
have to know where a chip came from. */
|
|
function chipTarget() {
|
|
return document.getElementById("attachments");
|
|
}
|
|
|
|
function chatId() {
|
|
var input = document.getElementById("file-input");
|
|
var url = (input && input.dataset.uploadUrl) || "";
|
|
var match = url.match(/chat_id=([^&]+)/);
|
|
return match ? decodeURIComponent(match[1]) : "";
|
|
}
|
|
|
|
function postForChip(url, body) {
|
|
var target = chipTarget();
|
|
if (!target) return Promise.resolve();
|
|
return fetch(url, { method: "POST", body: body, credentials: "same-origin" })
|
|
.then(function (response) { return response.text(); })
|
|
.then(function (html) {
|
|
target.insertAdjacentHTML("beforeend", html);
|
|
if (window.htmx) window.htmx.process(target.lastElementChild);
|
|
});
|
|
}
|
|
|
|
function attachLink() {
|
|
if (!window.lembas || !window.lembas.prompt) return;
|
|
window.lembas.prompt({
|
|
title: "Attach a web page",
|
|
message: "The page is fetched now and its text attached, so it will not " +
|
|
"change between writing this and sending it.",
|
|
placeholder: "https://example.com/article",
|
|
confirmLabel: "Fetch",
|
|
}).then(function (url) {
|
|
if (!url || !url.trim()) return;
|
|
var body = new FormData();
|
|
body.append("url", url.trim());
|
|
body.append("chat_id", chatId());
|
|
return postForChip("/api/files/link", body);
|
|
});
|
|
}
|
|
|
|
/* The knowledge dialog re-queries the server as you type rather than
|
|
filtering in the browser: the library is searched with FTS, which is what
|
|
makes it work at five hundred documents instead of five. */
|
|
function attachKnowledge() {
|
|
var dialog = document.createElement("dialog");
|
|
dialog.className = "dialog dialog--wide";
|
|
dialog.innerHTML =
|
|
'<div class="dialog__form">' +
|
|
'<h2 class="dialog__title">Attach from your library</h2>' +
|
|
'<input class="input" type="search" placeholder="Search your documents…" ' +
|
|
'aria-label="Search your documents">' +
|
|
'<div class="dialog__results"></div>' +
|
|
'<div class="dialog__actions"><button class="btn" type="button">Close</button></div>' +
|
|
"</div>";
|
|
document.body.appendChild(dialog);
|
|
|
|
var search = dialog.querySelector("input");
|
|
var results = dialog.querySelector(".dialog__results");
|
|
var close = dialog.querySelector("button");
|
|
|
|
function load(query) {
|
|
fetch("/api/files/knowledge-picker?q=" + encodeURIComponent(query || ""), {
|
|
credentials: "same-origin",
|
|
})
|
|
.then(function (response) { return response.text(); })
|
|
.then(function (html) { results.innerHTML = html; })
|
|
.catch(function () { results.textContent = "Could not load your library."; });
|
|
}
|
|
|
|
var pending = null;
|
|
search.addEventListener("input", function () {
|
|
clearTimeout(pending);
|
|
pending = setTimeout(function () { load(search.value); }, 200);
|
|
});
|
|
|
|
results.addEventListener("click", function (event) {
|
|
var option = event.target.closest("[data-attach-knowledge]");
|
|
if (!option) return;
|
|
var body = new FormData();
|
|
body.append("document_id", option.dataset.attachKnowledge);
|
|
body.append("chat_id", chatId());
|
|
postForChip("/api/files/from-knowledge", body);
|
|
finish();
|
|
});
|
|
|
|
function finish() {
|
|
dialog.close();
|
|
setTimeout(function () { dialog.remove(); }, 200);
|
|
}
|
|
|
|
close.addEventListener("click", finish);
|
|
dialog.addEventListener("cancel", function (event) {
|
|
event.preventDefault();
|
|
finish();
|
|
});
|
|
dialog.addEventListener("click", function (event) {
|
|
if (event.target === dialog) finish();
|
|
});
|
|
|
|
dialog.showModal();
|
|
load("");
|
|
search.focus();
|
|
}
|
|
|
|
document.addEventListener("click", function (event) {
|
|
var choice = event.target.closest("[data-attach]");
|
|
if (!choice) return;
|
|
event.preventDefault();
|
|
|
|
var kind = choice.dataset.attach;
|
|
if (kind === "file") document.getElementById("file-input").click();
|
|
else if (kind === "image") document.getElementById("image-input").click();
|
|
else if (kind === "link") attachLink();
|
|
else if (kind === "knowledge") attachKnowledge();
|
|
});
|
|
|
|
function setupDropzone() {
|
|
var zone = document.querySelector("[data-dropzone]");
|
|
if (!zone) return;
|
|
|
|
/* dragenter/dragleave fire for every child element the pointer crosses, so
|
|
a plain toggle flickers. Counting entries and exits is the standard fix. */
|
|
var depth = 0;
|
|
|
|
function hasFiles(event) {
|
|
return event.dataTransfer && Array.prototype.indexOf.call(
|
|
event.dataTransfer.types || [], "Files"
|
|
) !== -1;
|
|
}
|
|
|
|
zone.addEventListener("dragenter", function (event) {
|
|
if (!hasFiles(event)) return;
|
|
event.preventDefault();
|
|
depth += 1;
|
|
zone.classList.add("is-dropping");
|
|
});
|
|
|
|
zone.addEventListener("dragover", function (event) {
|
|
if (hasFiles(event)) event.preventDefault();
|
|
});
|
|
|
|
zone.addEventListener("dragleave", function () {
|
|
depth = Math.max(0, depth - 1);
|
|
if (depth === 0) zone.classList.remove("is-dropping");
|
|
});
|
|
|
|
zone.addEventListener("drop", function (event) {
|
|
if (!hasFiles(event)) return;
|
|
event.preventDefault();
|
|
depth = 0;
|
|
zone.classList.remove("is-dropping");
|
|
uploadFiles(event.dataTransfer.files);
|
|
});
|
|
|
|
/* Pasting a screenshot straight into the composer. Only files are taken;
|
|
pasted text must still behave as text. */
|
|
document.addEventListener("paste", function (event) {
|
|
var composer = event.target.closest("[data-composer-input]");
|
|
if (!composer || !event.clipboardData) return;
|
|
var files = Array.prototype.filter.call(
|
|
event.clipboardData.files || [], function (f) { return f && f.size; }
|
|
);
|
|
if (!files.length) return;
|
|
event.preventDefault();
|
|
uploadFiles(files);
|
|
});
|
|
}
|
|
|
|
/* --- Installing as an app ----------------------------------------------
|
|
Chromium fires beforeinstallprompt when it decides the app is installable
|
|
and lets the page defer the prompt. The event is the only handle on it, so
|
|
it is kept; there is no way to ask later whether one is available.
|
|
|
|
Nothing appears unless the browser offers it. Firefox and desktop Safari
|
|
never fire the event, and there is no useful button to show in their
|
|
place -- an "Install" that does nothing is worse than none. */
|
|
var installPrompt = null;
|
|
|
|
function revealInstall(show) {
|
|
document.querySelectorAll("[data-install-app]").forEach(function (el) {
|
|
el.hidden = !show;
|
|
});
|
|
}
|
|
|
|
function promptInstall() {
|
|
if (!installPrompt) return;
|
|
installPrompt.prompt();
|
|
installPrompt.userChoice.then(function () {
|
|
// A prompt is single-use, accepted or dismissed.
|
|
installPrompt = null;
|
|
revealInstall(false);
|
|
});
|
|
}
|
|
|
|
window.addEventListener("beforeinstallprompt", function (event) {
|
|
event.preventDefault();
|
|
installPrompt = event;
|
|
revealInstall(true);
|
|
});
|
|
|
|
window.addEventListener("appinstalled", function () {
|
|
installPrompt = null;
|
|
revealInstall(false);
|
|
});
|
|
|
|
window.lembas = {
|
|
applyTheme: applyTheme,
|
|
toggleTheme: toggleTheme,
|
|
copyText: copyText,
|
|
scrollThread: scrollThread,
|
|
autosize: autosize,
|
|
uploadFiles: uploadFiles,
|
|
promptInstall: promptInstall
|
|
};
|
|
|
|
/* --- Wiring ------------------------------------------------------------ */
|
|
document.addEventListener("click", function (event) {
|
|
var toggle = event.target.closest("[data-theme-toggle]");
|
|
if (toggle) {
|
|
event.preventDefault();
|
|
toggleTheme();
|
|
return;
|
|
}
|
|
|
|
var copy = event.target.closest("[data-copy]");
|
|
if (copy) {
|
|
event.preventDefault();
|
|
var source = document.getElementById(copy.dataset.copy);
|
|
if (source) copyText(source.textContent.trim(), copy);
|
|
return;
|
|
}
|
|
|
|
/* A suggestion card sends its prompt. One click, one reply -- filling the
|
|
box and waiting for Enter makes the card a form to review rather than a
|
|
thing to press. The built-in prompts are written to work sent cold: each
|
|
asks for what it needs, so the answer is a question back rather than a
|
|
guess at material nobody has given yet. */
|
|
var suggestion = event.target.closest("[data-suggestion]");
|
|
if (suggestion) {
|
|
event.preventDefault();
|
|
var input = document.querySelector("[data-composer-input]");
|
|
if (!input) return;
|
|
input.value = suggestion.dataset.suggestion;
|
|
autosize(input);
|
|
var form = input.closest("form");
|
|
/* requestSubmit, not submit(): it fires the submit event, which is what
|
|
htmx is listening for. Same call the Enter key makes. */
|
|
if (form) form.requestSubmit();
|
|
return;
|
|
}
|
|
|
|
/* Show/hide a panel by selector, so templates do not each carry their own
|
|
inline toggle script. */
|
|
var toggle = event.target.closest("[data-toggle]");
|
|
if (toggle) {
|
|
event.preventDefault();
|
|
var panel = document.querySelector(toggle.dataset.toggle);
|
|
if (!panel) return;
|
|
var nowOpen = panel.hasAttribute("hidden");
|
|
panel.toggleAttribute("hidden");
|
|
toggle.setAttribute("aria-expanded", nowOpen ? "true" : "false");
|
|
toggle.classList.toggle("is-active", nowOpen);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("input", function (event) {
|
|
if (event.target.matches("[data-autosize]")) autosize(event.target);
|
|
});
|
|
|
|
/* A "select all" box driving every checkbox inside a container. Scoped to a
|
|
selector rather than the whole page, so a list can carry more than one. */
|
|
document.addEventListener("change", function (event) {
|
|
var master = event.target.closest("[data-select-all]");
|
|
if (!master) return;
|
|
var scope = document.querySelector(master.dataset.selectAll);
|
|
if (!scope) return;
|
|
scope.querySelectorAll('input[type="checkbox"]').forEach(function (box) {
|
|
box.checked = master.checked;
|
|
});
|
|
});
|
|
|
|
/* Enter sends, Shift+Enter inserts a newline -- the convention every chat
|
|
application uses. Left alone on touch devices, where there is no easy
|
|
Shift and Enter should mean "new line". */
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key !== "Enter" || event.shiftKey) return;
|
|
var composer = event.target.closest("[data-composer-input]");
|
|
if (!composer) return;
|
|
if (window.matchMedia("(pointer: coarse)").matches) return;
|
|
event.preventDefault();
|
|
var form = composer.closest("form");
|
|
if (form && composer.value.trim()) form.requestSubmit();
|
|
});
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
document.querySelectorAll("[data-autosize]").forEach(autosize);
|
|
scrollThread(true);
|
|
applyTheme(currentTheme());
|
|
setupDropzone();
|
|
});
|
|
|
|
/* After any htmx swap: re-measure the composer and follow new content. */
|
|
document.body.addEventListener("htmx:afterSwap", function () {
|
|
document.querySelectorAll("[data-autosize]").forEach(autosize);
|
|
scrollThread(false);
|
|
});
|
|
|
|
/* Tokens arriving over SSE are appended outside the normal swap cycle. */
|
|
document.body.addEventListener("htmx:sseMessage", function () {
|
|
scrollThread(false);
|
|
});
|
|
})();
|