Fix attachments never being sent with the message

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>
This commit is contained in:
Jaroslav Beneš
2026-07-21 14:08:52 +02:00
parent 75edae039b
commit 476812f119
6 changed files with 206 additions and 36 deletions
+14 -3
View File
@@ -82,9 +82,20 @@ async def chat_index(request: Request, db: Db, user: RequiredUser, model: str =
creating a row for a chat that may never be sent.
"""
context = _chat_context(db, user, None)
preselected = next(
(m for m in context["models"] if m.model_id == model), None
) or (context["models"][0] if context["models"] else None)
# Fall back to the same choice a new chat would make -- the user's default,
# then the instance default, then first in order. Using models[0] here
# instead would show a model the chat is not going to use, which matters:
# the composer decides from it whether to warn that images will be dropped.
preselected = next((m for m in context["models"] if m.model_id == model), None)
if preselected is None:
chosen = chat_service.default_model(db, user)
if chosen is not None:
preselected = next(
(m for m in context["models"] if m.model_id == chosen[0]), None
)
if preselected is None and context["models"]:
preselected = context["models"][0]
return render(
request,
+11 -3
View File
@@ -312,16 +312,24 @@
background: var(--bg);
}
.composer__inner { max-width: var(--thread-max-width); margin: 0 auto; }
/* A column: chips on top, then the control row. The chips are inside the form
so their hidden file_ids inputs are submitted with the message. */
.composer__form {
display: flex;
gap: var(--sp-1);
align-items: flex-end;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--surface);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.composer__row {
display: flex;
gap: var(--sp-1);
align-items: flex-end;
}
/* Attach and send are the same size and sit on the same baseline as the last
line of the textarea, so the control row reads as one object. */
.composer__btn { flex: none; align-self: flex-end; border-radius: var(--radius-full); }
@@ -386,10 +394,10 @@
.composer { position: relative; }
.composer__attachments {
margin: 0 0 var(--sp-2);
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
padding: var(--sp-1) var(--sp-1) 0;
}
.composer__attachments:empty { display: none; }
+5 -3
View File
@@ -107,15 +107,17 @@
rejection) then appears immediately, and a large file cannot make the send
button appear to hang. */
function uploadFiles(fileList) {
var form = document.getElementById("upload-form");
var input = document.getElementById("file-input");
var target = document.getElementById("attachments");
if (!form || !target || !fileList || !fileList.length) return;
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(form.getAttribute("hx-post"), { method: "POST", body: body })
fetch(url, { method: "POST", body: body, credentials: "same-origin" })
.then(function (response) { return response.text(); })
.then(function (html) {
target.insertAdjacentHTML("beforeend", html);
+30 -27
View File
@@ -8,21 +8,23 @@
redirects, and the reply streams on arrival because the page renders the
unfinished assistant message with its sse-connect. That is what stops an
opened-and-abandoned chat ever being written to the database.
The attachment chips live INSIDE this form on purpose. Each carries a hidden
file_ids input, and being inside the form is what gets them serialised with
the message. Keeping them outside and reaching for hx-include does not work:
that attribute only has an effect on the element issuing the request.
#}
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
{% if can.get("files.upload") %}
<form id="upload-form" hx-post="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
hx-target="#attachments" hx-swap="beforeend"
hx-encoding="multipart/form-data" hx-on::after-request="this.reset()">
<input class="visually-hidden" type="file" name="file" id="file-input" multiple
accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
</form>
{# Outside the form: it is only ever read by JavaScript, and inside it would
be submitted as an empty file part on every message. #}
<input class="visually-hidden" type="file" id="file-input" multiple
data-upload-url="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
{% endif %}
<div class="composer__inner">
<div class="composer__attachments" id="attachments"></div>
<form class="composer__form"
{% if chat %}
hx-post="/api/chats/{{ chat.id }}/messages"
@@ -37,30 +39,31 @@
hx-post="/api/chats/start" hx-swap="none"
{% endif %}>
{# The chips live outside this form, so their hidden inputs are pulled in
explicitly at submit time. #}
<div hx-include="#attachments" hidden></div>
<div class="composer__attachments" id="attachments"></div>
{% if not chat and current_model %}
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
{% endif %}
{% if can.get("files.upload") %}
<button class="btn btn--icon composer__btn" type="button"
aria-label="Attach a file" title="Attach a file"
onclick="document.getElementById('file-input').click()">
{{ icon("attach") }}
</button>
{% endif %}
<div class="composer__row">
{% if can.get("files.upload") %}
<button class="btn btn--icon composer__btn" type="button"
aria-label="Attach a file" title="Attach a file"
onclick="document.getElementById('file-input').click()">
{{ icon("attach") }}
</button>
{% endif %}
<textarea class="composer__input" name="content" rows="1"
data-autosize data-max-height="320" data-composer-input
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
<textarea class="composer__input" name="content" rows="1"
data-autosize data-max-height="320" data-composer-input
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
<button class="btn btn--primary btn--icon composer__btn" type="submit"
aria-label="Send">
{{ icon("send") }}
</button>
<button class="btn btn--primary btn--icon composer__btn" type="submit"
aria-label="Send">
{{ icon("send") }}
</button>
</div>
</form>
<p class="composer__hint">