248dec2961
A clock in the top-right starts one. It is never listed in the sidebar and is swept a day after the last thing said in it. A real row rather than something held in the browser, because a reload, a crash or a background tab all look identical from here -- "delete when you navigate away" would lose conversations people meant to keep. The flag rides in the URL (/chat?temporary=1) rather than in JavaScript, so it survives a reload and can be bookmarked, and the composer carries it as a hidden field beside model_id. Keep clears the flag. Without a way out, a conversation that turns out to matter is destroyed a day later with no recourse, and people would find that out exactly once. archived was filtered in three places and temporary mirrors all three, plus Folder.visible_chats. It also skips the unread flag in _persist: there is no sidebar row for the dot to land on, and the toast would name a chat nobody can navigate to. The sweep measures age from the newest message, not from the chat row. created_at would destroy a conversation still in use at hour 23, and updated_at does not move when a message is inserted -- onupdate fires on an UPDATE of the chat, and adding a message is not one. It runs at startup beside the existing upload sweep. Deleting a chat cascades its rows but leaves the files on disk; only the orphan sweep unlinks anything, and it looks only at uploads that were never attached. files.remove_files_for_chats() closes that for the new sweep. The same hole in delete_chat is pre-existing and left for its own change, which can now call the same helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
7.5 KiB
HTML
166 lines
7.5 KiB
HTML
{% from "_macros.html" import icon %}
|
|
{#
|
|
The composer, used both inside an existing chat and on /chat where no chat
|
|
row exists yet.
|
|
|
|
The only difference is where it posts. With a chat, the turn is appended to
|
|
the thread in place; without one, /api/chats/start creates the chat and
|
|
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") %}
|
|
{# Outside the form: it is only ever read by JavaScript, and inside it would
|
|
be submitted as an empty file part on every message.
|
|
|
|
Two inputs rather than one whose accept attribute is rewritten: changing
|
|
accept and then calling click() in the same tick is unreliable in Safari,
|
|
and two hidden inputs cost nothing. #}
|
|
<input class="visually-hidden" type="file" id="file-input" multiple
|
|
data-upload-url="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
|
|
accept=".pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
|
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
<input class="visually-hidden" type="file" id="image-input" multiple accept="image/*"
|
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
{% endif %}
|
|
|
|
<div class="composer__inner">
|
|
<form class="composer__form"
|
|
{% if chat %}
|
|
hx-post="/api/chats/{{ chat.id }}/messages"
|
|
hx-target="#thread" hx-swap="beforeend"
|
|
hx-on::after-request="if (event.detail.successful) {
|
|
this.reset();
|
|
document.getElementById('attachments').replaceChildren();
|
|
window.lembas.autosize(this.querySelector('textarea'));
|
|
window.lembas.scrollThread(true);
|
|
}"
|
|
{% else %}
|
|
hx-post="/api/chats/start" hx-swap="none"
|
|
{% endif %}>
|
|
|
|
<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 not chat and starting_temporary %}
|
|
<input type="hidden" name="temporary" value="true">
|
|
{% endif %}
|
|
|
|
<div class="composer__row">
|
|
{% if can.get("files.upload") %}
|
|
{# A menu rather than the file picker straight away: there are four ways
|
|
to attach something now, and only one of them is a file on disk.
|
|
Uses the same picker machinery as the model chooser -- see ui.js. #}
|
|
<div class="picker picker--up" data-picker>
|
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
|
aria-haspopup="menu" aria-expanded="false"
|
|
aria-label="Attach" title="Attach">
|
|
{{ icon("attach") }}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
|
hidden aria-label="Attach">
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="file">
|
|
{{ icon("attach", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">File</span>
|
|
<span class="picker__option-note">PDF, text, code</span>
|
|
</span>
|
|
</button>
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="image">
|
|
{{ icon("image", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Image</span>
|
|
<span class="picker__option-note">Sent only to vision models</span>
|
|
</span>
|
|
</button>
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="link">
|
|
{{ icon("link", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Link</span>
|
|
<span class="picker__option-note">Fetch a page and attach its text</span>
|
|
</span>
|
|
</button>
|
|
{% if can.get("library.use") %}
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="knowledge">
|
|
{{ icon("archive", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Knowledge</span>
|
|
<span class="picker__option-note">From your library</span>
|
|
</span>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% 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>
|
|
|
|
{% if can_dictate %}
|
|
{# Recording is started and stopped by the same button; audio.js swaps
|
|
data-mic-state and the icon with it. #}
|
|
<button class="btn btn--icon composer__btn composer__mic" type="button"
|
|
data-mic data-mic-state="idle"
|
|
aria-label="Dictate a message" title="Dictate a message">
|
|
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
|
|
<span class="composer__icon composer__icon--recording" aria-hidden="true">
|
|
{{ icon("stop-circle") }}
|
|
</span>
|
|
</button>
|
|
{% endif %}
|
|
|
|
{#
|
|
One button, two jobs. While a reply is being written it becomes Stop,
|
|
because that is where the hand already is and a second button sitting
|
|
permanently beside Send is clutter that is wrong most of the time.
|
|
|
|
ui.js flips data-composer-action, and the type with it: as `submit`
|
|
the form's own handler sends, as `button` the click handler stops.
|
|
Both icons are rendered here and chosen in CSS, so the swap costs no
|
|
layout and cannot flash an empty button.
|
|
#}
|
|
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
|
data-composer-action="send" aria-label="Send">
|
|
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
|
|
<span class="composer__icon composer__icon--stop" aria-hidden="true">
|
|
<span class="composer__stop-square"></span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<p class="composer__hint">
|
|
Enter to send, Shift+Enter for a new line.
|
|
{% if can.get("files.upload") %}
|
|
Drag files in, or paste an image.
|
|
{% if current_model and not current_model.capabilities_json.get("vision") %}
|
|
<strong>{{ current_model.label }} has no vision</strong>, so images
|
|
will not be sent — documents still will.
|
|
{% endif %}
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
|
|
{% if can.get("files.upload") %}
|
|
<div class="dropzone-overlay" aria-hidden="true">
|
|
{{ icon("attach", "icon--lg") }}
|
|
<span>Drop to attach</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|