f744232d25
Uploading an image showed the chip and then did nothing: the file was stored but never reached the model. Two causes, both in the composer template. The chips live in #attachments, and each carries the hidden file_ids input that binds it to the message. That container sat OUTSIDE the <form>, with an `hx-include="#attachments"` on a hidden <div> inside the form meant to pull it back in. That attribute only has an effect on the element issuing the request -- on a child of it, it does nothing. So the form serialised content and nothing else, and post_message saw no file_ids at all. Fixed by putting #attachments inside the form, where the inputs are submitted because they are in the form, rather than because of an attribute that has to be wired correctly. The file input stays outside, since inside it would submit an empty file part on every message. Second: /chat preselected models[0] rather than the model a new chat would actually use. With a vision model set as the default and a non-vision one first in the admin ordering, the composer showed the wrong model, sent the wrong model, and told the user images *would* be sent when they would not. It now resolves through default_model(), the same path /start uses. Every server-side test passed throughout, because the bug was entirely in the wiring between template and browser. Added tests that serialise the rendered form the way a browser does -- every named input inside <form> -- and assert file_ids is among them and the image reaches the model as a content part. Verified they fail with the old markup restored, then pass again. Confirmed end to end against gemma4-e4b-q8: given a drawing, it replied "Left: Green Circle / Right: Orange Triangle". 220 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
JavaScript
280 lines
10 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 */ }
|
|
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
window.lembas = {
|
|
applyTheme: applyTheme,
|
|
toggleTheme: toggleTheme,
|
|
copyText: copyText,
|
|
scrollThread: scrollThread,
|
|
autosize: autosize,
|
|
uploadFiles: uploadFiles
|
|
};
|
|
|
|
/* --- 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;
|
|
}
|
|
|
|
/* 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);
|
|
});
|
|
})();
|