diff --git a/README.md b/README.md index 95f2f92..d650571 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,12 @@ runtime. Clone it, `pip install -e .`, run it. the first message, so an abandoned one never clutters the sidebar - **System prompts** — instance-wide, per-model and per-chat, with the most specific winning outright -- **Reasoning display** — thinking from reasoning models streams into its own - collapsible block, labelled with how long it took, and is never replayed as +- **Reasoning display** — thinking streams into its own collapsible block + (closed by default), labelled with how long it took, and is never replayed as context +- **Live Markdown** — formatting appears as the model writes, not at the end +- **Stop and rewind** — cut a reply short and keep what arrived, or edit an + earlier message and run the conversation on from there - **Attachments** — drag, paste or pick images, PDFs and text files. Images are downscaled and sent to vision models; PDF and text content is extracted and put in the prompt diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 25fbc32..a465ddb 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -9,6 +9,7 @@ from collections.abc import AsyncIterator from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, Response, StreamingResponse +from sqlalchemy import select from sqlalchemy.orm import Session as DBSession from lembas.api.deps import Db, RequiredUser, require_permission @@ -32,6 +33,21 @@ log = logging.getLogger(__name__) router = APIRouter(prefix="/api/chats", tags=["chats"]) +# Message ids whose generation has been asked to stop. The generator checks +# this between chunks and finalises with whatever it has. +# +# In-process, which is correct for the single-worker deployment this ships +# with: the request that stops a stream and the task producing it are in the +# same process. Running multiple workers would need this in the database or a +# broker instead -- see deploy/README.md. +_CANCELLED: set[str] = set() + +# How often the partially rendered reply is pushed to the browser. Markdown is +# re-rendered from scratch each time, so this trades a little server work for +# formatting that appears as the model writes rather than all at once at the +# end. 100ms is below the threshold where the eye reads it as stepping. +RENDER_INTERVAL = 0.1 + def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat: chat = db.get(Chat, chat_id) @@ -221,6 +237,9 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: splitter = ReasoningSplitter() started = time.monotonic() reasoning_started: float | None = None + last_render = 0.0 + dirty = False + stopped = False try: endpoint, model_id = chat_service.resolve_endpoint(db, chat) @@ -261,18 +280,31 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: if reasoning_started is not None and not reasoning_ms: reasoning_ms = int((time.monotonic() - reasoning_started) * 1000) accumulated.append(piece) - yield sse.event("token", escape_text(piece)) + dirty = True # Hand control back so the event is flushed rather than # batched behind a fast generator. await asyncio.sleep(0) + # Re-render the answer so far, at most every RENDER_INTERVAL. + # Markdown is rendered whole rather than appended, because a + # list or a code fence is only correct once its context is + # known -- and partial syntax resolves itself as more arrives. + now = time.monotonic() + if dirty and now - last_render >= RENDER_INTERVAL: + yield sse.event("render", render_markdown("".join(accumulated))) + last_render, dirty = now, False + await asyncio.sleep(0) + + if message_id in _CANCELLED: + stopped = True + break + for kind, piece in splitter.flush(): if kind == REASONING: thinking.append(piece) yield sse.event("reasoning", escape_text(piece)) else: accumulated.append(piece) - yield sse.event("token", escape_text(piece)) except LLMError as exc: error = exc.message @@ -280,9 +312,11 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: except asyncio.CancelledError: # The reader navigated away or closed the tab. Keep whatever was # produced so the partial reply is still there on reload. + _CANCELLED.discard(message_id) message.content = "".join(accumulated) message.reasoning = "".join(thinking) message.complete = True + message.stopped = True db.commit() raise except Exception as exc: # noqa: BLE001 - must not kill the stream silently @@ -293,11 +327,14 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: # Reasoning ran to the end without an answer following it. reasoning_ms = int((time.monotonic() - reasoning_started) * 1000) + _CANCELLED.discard(message_id) + message.content = "".join(accumulated) message.reasoning = "".join(thinking) message.reasoning_ms = reasoning_ms message.error = error or "" message.complete = True + message.stopped = stopped log.debug( "chat %s: %d chars answer, %d chars reasoning, %.1fs total", chat_id, @@ -340,6 +377,140 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: yield sse.event("close", "") +def _thread_context(db: DBSession, chat: Chat, user: User) -> dict: + """Everything chat/_thread.html needs to render the conversation.""" + messages = list( + db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)) + ) + return { + "chat": chat, + "user": user, + "messages": messages, + "bodies": { + m.id: render_markdown(m.content) + for m in messages + if m.role == ROLE_ASSISTANT and m.content + }, + "models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)}, + } + + +def _messages_after(db: DBSession, message: Message) -> list[Message]: + return list( + db.scalars( + select(Message) + .where(Message.chat_id == message.chat_id, Message.created_at > message.created_at) + .order_by(Message.created_at) + ) + ) + + +@router.get("/{chat_id}/messages/{message_id}/edit") +async def edit_form( + request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str +) -> Response: + """Swap one of the reader's own turns into an editable form.""" + chat = _owned_chat(db, chat_id, user.id) + message = db.get(Message, message_id) + if message is None or message.chat_id != chat.id or message.role != ROLE_USER: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.") + + return templates.TemplateResponse( + request, + "chat/_edit_form.html", + { + "request": request, + "chat": chat, + "user": user, + "message": message, + "following": len(_messages_after(db, message)), + }, + ) + + +@router.get("/{chat_id}/messages/{message_id}/cancel-edit") +async def cancel_edit( + request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str +) -> Response: + """Put the bubble back, unchanged.""" + chat = _owned_chat(db, chat_id, user.id) + message = db.get(Message, message_id) + if message is None or message.chat_id != chat.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.") + + return templates.TemplateResponse( + request, + "chat/_message.html", + { + "request": request, + "chat": chat, + "user": user, + "message": message, + "body_html": "", + "models_by_id": {}, + }, + ) + + +@router.post("/{chat_id}/messages/{message_id}/edit") +async def edit_message( + request: Request, + db: Db, + user: RequiredUser, + chat_id: str, + message_id: str, + content: str = Form(...), +) -> Response: + """Rewrite one of the reader's turns and run the conversation on from there. + + Everything after the edited message is deleted rather than branched. A + branch would need a UI for choosing between versions, and "go back and try + again from here" is what was actually asked for -- the simpler behaviour is + also the one people expect from every other chat client. + """ + chat = _owned_chat(db, chat_id, user.id) + message = db.get(Message, message_id) + if message is None or message.chat_id != chat.id or message.role != ROLE_USER: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.") + + content = content.strip() + if not content and not message.attachments: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "A message cannot be empty.") + + message.content = content + + # Attachments cascade with their message, so the files go too. + discarded = _messages_after(db, message) + for later in discarded: + db.delete(later) + db.commit() + + chat_service.create_message( + db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id + ) + log.info("chat %s rewound to a message, %d discarded", chat.id, len(discarded)) + + return templates.TemplateResponse( + request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)} + ) + + +@router.post("/{chat_id}/messages/{message_id}/stop") +async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response: + """Ask a running generation to stop. + + Whatever has arrived is kept: a half-written answer the reader chose to cut + short is still worth having, and discarding it would be a surprise. + """ + chat = _owned_chat(db, chat_id, user.id) + message = db.get(Message, message_id) + if message is None or message.chat_id != chat.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.") + + _CANCELLED.add(message.id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.patch("/{chat_id}") async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response: """Partially update a chat. diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py index a44c158..9a80b18 100644 --- a/src/lembas/db/models/chat.py +++ b/src/lembas/db/models/chat.py @@ -118,6 +118,9 @@ class Message(UUIDPrimaryKey, Timestamps, Base): error: Mapped[str] = mapped_column(Text, default="") # False while a reply is still streaming; flipped when the stream ends. complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + # True when the reader pressed Stop. Distinct from `error`: the text that + # did arrive is kept and is perfectly usable, it is just cut short. + stopped: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) chat: Mapped[Chat] = relationship(back_populates="messages") attachments: Mapped[list[Attachment]] = relationship( # noqa: F821 diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index f940af4..ce24828 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -578,3 +578,200 @@ button, input, textarea, select { } .sidebar[data-collapsed="true"] { display: none; } } + +/* --- Toasts ---------------------------------------------------------------- + Bottom-right, stacked, above dialogs' backdrop but out of the way of the + composer. +*/ +.toasts { + position: fixed; + right: var(--sp-4); + bottom: var(--sp-4); + z-index: 60; + display: flex; + flex-direction: column; + gap: var(--sp-2); + max-width: min(24rem, calc(100vw - var(--sp-8))); + pointer-events: none; +} + +.toast { + display: flex; + align-items: flex-start; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + background: var(--surface-raised); + box-shadow: var(--shadow-lg); + font-size: var(--text-sm); + pointer-events: auto; + opacity: 0; + transform: translateY(0.5rem); + transition: opacity var(--transition), transform var(--transition); +} +.toast.is-in { opacity: 1; transform: none; } +.toast--success { border-left-color: var(--success); } +.toast--error { border-left-color: var(--danger); } +.toast--warning { border-left-color: var(--warning); } +.toast__text { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.toast__close { + flex: none; + border: 0; + background: none; + color: var(--ink-faint); + cursor: pointer; + font-size: var(--text-lg); + line-height: 1; + padding: 0 0.15rem; +} +.toast__close:hover { color: var(--ink); } + +/* --- Dialogs ---------------------------------------------------------------- + gives focus trapping, Escape and page inertness for free. +*/ +.dialog { + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: var(--surface); + color: var(--ink); + padding: 0; + max-width: min(28rem, calc(100vw - var(--sp-8))); + width: 100%; + box-shadow: var(--shadow-lg); +} +.dialog::backdrop { background: var(--scrim); backdrop-filter: blur(2px); } + +.dialog__form { padding: var(--sp-5); display: flex; flex-direction: column; gap: var(--sp-3); } +.dialog__title { font-size: var(--text-lg); } +.dialog__message { + margin: 0; + color: var(--ink-muted); + font-size: var(--text-sm); + line-height: var(--leading-relaxed); + overflow-wrap: anywhere; +} +.dialog__actions { + display: flex; + justify-content: flex-end; + gap: var(--sp-2); + margin-top: var(--sp-2); +} + +/* A destructive confirmation needs the weight of a filled button, not the + outline treatment .btn--danger gives a row action. */ +.btn--danger-solid { + background: var(--danger); + border-color: var(--danger); + color: var(--ink-inverse); +} +.btn--danger-solid:hover:not(:disabled) { + background: var(--danger-hover); + border-color: var(--danger-hover); + color: var(--ink-inverse); +} + +/* --- Model picker ---------------------------------------------------------- + Built by hand because a cannot render an avatar, a description or capability badges, so + the control is built out of buttons and a hidden input. Keyboard behaviour is + written out by hand for the same reason -- there is no native widget doing it + for us. +*/ +(function () { + "use strict"; + + function close(picker) { + var menu = picker.querySelector("[data-picker-menu]"); + var toggle = picker.querySelector("[data-picker-toggle]"); + if (!menu || menu.hidden) return; + menu.hidden = true; + toggle.setAttribute("aria-expanded", "false"); + } + + function closeAll(except) { + document.querySelectorAll("[data-picker]").forEach(function (picker) { + if (picker !== except) close(picker); + }); + } + + function open(picker) { + var menu = picker.querySelector("[data-picker-menu]"); + var toggle = picker.querySelector("[data-picker-toggle]"); + closeAll(picker); + menu.hidden = false; + toggle.setAttribute("aria-expanded", "true"); + + var filter = menu.querySelector("[data-picker-filter]"); + if (filter) { + filter.value = ""; + applyFilter(menu, ""); + filter.focus(); + } else { + var selected = menu.querySelector(".picker__option.is-selected") || + menu.querySelector(".picker__option"); + if (selected) selected.focus(); + } + // Keep the chosen model in view when the list is long. + var current = menu.querySelector(".picker__option.is-selected"); + if (current) current.scrollIntoView({ block: "nearest" }); + } + + function applyFilter(menu, needle) { + var shown = 0; + menu.querySelectorAll(".picker__option").forEach(function (option) { + var match = !needle || option.dataset.pickerSearch.indexOf(needle) !== -1; + option.hidden = !match; + if (match) shown += 1; + }); + var empty = menu.querySelector("[data-picker-empty]"); + if (empty) empty.hidden = shown > 0; + } + + function choose(picker, value) { + var navigate = picker.querySelector("[data-picker-navigate]"); + if (navigate) { + window.location = navigate.dataset.pickerNavigate + encodeURIComponent(value); + return; + } + var input = picker.querySelector("[data-picker-input]"); + if (input) { + input.value = value; + // htmx listens for change on the input; assigning .value does not fire it. + input.dispatchEvent(new Event("change", { bubbles: true })); + } + + // Reflect the choice immediately rather than waiting for a reload. + picker.querySelectorAll(".picker__option").forEach(function (option) { + var selected = option.dataset.pickerValue === value; + option.classList.toggle("is-selected", selected); + option.setAttribute("aria-selected", selected ? "true" : "false"); + }); + var chosen = picker.querySelector('[data-picker-value="' + CSS.escape(value) + '"]'); + var label = picker.querySelector(".picker__label"); + var avatar = picker.querySelector(".picker__button .picker__avatar"); + if (chosen && label) { + label.textContent = chosen.querySelector(".picker__option-name").textContent.trim(); + } + if (chosen && avatar) { + var source = chosen.querySelector(".picker__avatar"); + if (source) avatar.replaceWith(source.cloneNode(true)); + } + close(picker); + if (window.lembas && window.lembas.notify) { + window.lembas.notify("Model switched to " + (label ? label.textContent : value), { + kind: "info", timeout: 2000, + }); + } + } + + document.addEventListener("click", function (event) { + var toggle = event.target.closest("[data-picker-toggle]"); + if (toggle) { + var picker = toggle.closest("[data-picker]"); + var menu = picker.querySelector("[data-picker-menu]"); + if (menu.hidden) open(picker); else close(picker); + return; + } + + var option = event.target.closest("[data-picker-value]"); + if (option) { + choose(option.closest("[data-picker]"), option.dataset.pickerValue); + return; + } + + if (!event.target.closest("[data-picker-menu]")) closeAll(null); + }); + + document.addEventListener("input", function (event) { + if (!event.target.matches("[data-picker-filter]")) return; + applyFilter( + event.target.closest("[data-picker-menu]"), + event.target.value.trim().toLowerCase() + ); + }); + + document.addEventListener("keydown", function (event) { + var picker = event.target.closest("[data-picker]"); + if (!picker) return; + var menu = picker.querySelector("[data-picker-menu]"); + + if (event.key === "Escape" && !menu.hidden) { + event.preventDefault(); + close(picker); + picker.querySelector("[data-picker-toggle]").focus(); + return; + } + + if (menu.hidden) { + if (event.key === "ArrowDown" || event.key === "Enter") { + if (event.target.matches("[data-picker-toggle]")) { + event.preventDefault(); + open(picker); + } + } + return; + } + + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + var options = Array.prototype.filter.call( + menu.querySelectorAll(".picker__option"), function (o) { return !o.hidden; } + ); + if (!options.length) return; + var at = options.indexOf(document.activeElement); + var step = event.key === "ArrowDown" ? 1 : -1; + var next = at === -1 ? 0 : (at + step + options.length) % options.length; + options[next].focus(); + }); +})(); diff --git a/src/lembas/web/templates/admin/_connection_row.html b/src/lembas/web/templates/admin/_connection_row.html index 6dfc6a3..b402d61 100644 --- a/src/lembas/web/templates/admin/_connection_row.html +++ b/src/lembas/web/templates/admin/_connection_row.html @@ -90,7 +90,7 @@ diff --git a/src/lembas/web/templates/admin/groups.html b/src/lembas/web/templates/admin/groups.html index 7ff3c16..02076ea 100644 --- a/src/lembas/web/templates/admin/groups.html +++ b/src/lembas/web/templates/admin/groups.html @@ -158,7 +158,8 @@ + {% endif %} + +
+ {% for model in models %} + + {% endfor %} +
+ + + + {% if chat %} + {# Changing the value fires the PATCH; htmx serialises the hidden input. #} +
+ +
+ {% else %} + {# No chat yet: selecting navigates so the whole composer re-renders with the + right vision warning and the right hidden model_id. #} + + {% endif %} + diff --git a/src/lembas/web/templates/chat/_thread.html b/src/lembas/web/templates/chat/_thread.html new file mode 100644 index 0000000..f6fc28d --- /dev/null +++ b/src/lembas/web/templates/chat/_thread.html @@ -0,0 +1,10 @@ +{# + The whole thread. Returned after a rewind, which changes an arbitrary number + of messages at once -- replacing the lot is simpler and less error-prone than + working out which individual bubbles to remove. +#} +{% for message in messages %} + {% with body_html = bodies.get(message.id, "") %} + {% include "chat/_message.html" %} + {% endwith %} +{% endfor %} diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html index 0aaf475..e87ec2d 100644 --- a/src/lembas/web/templates/chat/index.html +++ b/src/lembas/web/templates/chat/index.html @@ -27,26 +27,7 @@
{% if models %} {% if can.get("chat.model_select") or not chat %} - {# Models are listed in the administrator's order. Pinning is a - sidebar shortcut and deliberately does not reorder this. #} - + {% include "chat/_model_picker.html" %} {% elif current_model %} {{ current_model.label }} {% endif %} diff --git a/src/lembas/web/templates/partials/_chat_link.html b/src/lembas/web/templates/partials/_chat_link.html index 307ff7e..4e1145a 100644 --- a/src/lembas/web/templates/partials/_chat_link.html +++ b/src/lembas/web/templates/partials/_chat_link.html @@ -13,6 +13,7 @@