Background generation, unread replies, send/stop, PLAN.md

**Replies now run in the background.** Generation was driven by the SSE
request, so navigating away or opening another chat cut the answer off
mid-sentence. services/generation.py owns the work as its own task and
the SSE endpoint merely follows it. Verified: attached briefly, closed
the connection, went to another page -- the reply finished anyway, 832
characters, not marked stopped, auto-titled.

Reattaching works because both `render` and `reasoning` frames now carry
the whole block rather than a delta. A follower arriving late has no
earlier fragments to append to, so deltas would leave it permanently
missing the beginning. Verified: attached six seconds in and the first
frame already contained 517 characters written while nobody watched.

**Unread indicator.** A reply that lands with no follower attached marks
its chat unread; the sidebar polls every 10s for out-of-band dot spans
plus an HX-Trigger that raises a toast. Polled rather than pushed: a
browser sitting on another chat has no connection to the one that
finished, and an always-on channel per tab is a lot of machinery for a
green dot. `unread_notified` stops the same arrival being announced
every tick. Follower count is what decides "was anyone watching", so
reading it as it arrives does not mark it unread -- verified both ways.

**Stop is the send button.** While a reply is being written the send
button becomes a red stop square, found via a MutationObserver on the
thread since the composer and the streaming bubble are far apart in the
document. The in-bubble Stop is gone.

**Attachment border removed.** As asked -- an attachment is a picture,
and the frame only ever drew at the wrong width. The anchor now
shrink-wraps and the img's width/height attributes are overridden so a
small image shows at its own size.

Adds PLAN.md: what is built, what is not, known limits, and the
decisions that look like oversights until you know the reason.

239 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:52:28 +02:00
parent a071d8486b
commit de178837b8
14 changed files with 840 additions and 210 deletions
+22 -10
View File
@@ -20,6 +20,7 @@ lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 230 tests, ~9s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate all SVG artwork
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
@@ -191,17 +192,28 @@ null in the composer; `files.claim()` binds them, and only unclaimed rows owned
by that user, so a forged id cannot pull in someone else's file. Abandoned ones
are swept at startup.
**Markdown renders progressively, server-side.** The stream sends `render`
events carrying the whole answer re-rendered from Markdown, at most every
`RENDER_INTERVAL`, swapped with `innerHTML`. Appending raw tokens instead would
mean formatting only appearing at the end -- a list or code fence is only
correct once its context exists, so partial output must be re-rendered whole
rather than appended to.
**Generation is a background task; the SSE endpoint only follows it.**
`services/generation.py` owns the work and the registry; `api/chats.py:_follow`
watches a `Generation` and streams what it sees. Closing the connection does
NOT stop the reply -- that was the old behaviour and it cut answers off when
the reader navigated away. Any route that creates an assistant placeholder must
also call `generation.ensure()`.
**Stopping a stream is an in-process set.** `api/chats.py:_CANCELLED` holds
message ids the reader asked to stop; the generator checks it between chunks.
Correct for the single-worker deployment this ships with; multiple workers
would need it in the database or a broker.
**Stream frames carry whole blocks, not deltas.** Both `render` and `reasoning`
send the complete text each time. That is what makes reattaching mid-reply
work: a follower arriving late has no earlier fragments to append to. It also
means Markdown is re-rendered whole, which is required anyway -- a list or code
fence is only correct once its context exists.
**Stopping sets a flag the producer checks.** `generation.request_stop()`;
whatever arrived is kept and the message is marked `stopped`, which is distinct
from `error`. In-process, so single-worker only.
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
announced every tick. Re-rendering the whole sidebar instead would reset the
folder open/closed state every 10 seconds.
**Editing rewinds, it does not branch.** `POST .../messages/{id}/edit` rewrites
a user turn and **deletes everything after it**. Branching would need a UI for
+171
View File
@@ -0,0 +1,171 @@
# LLeMbas — plan and status
Where the project is, what is deliberately not built yet, and the decisions
that would be expensive to revisit. Kept current as work lands; the detail of
*how* things work lives in [`CLAUDE.md`](CLAUDE.md).
**Status:** usable daily. Streaming chat, attachments, reasoning, users and
groups, model administration. 230 tests, `ruff` clean.
---
## The shape of it
A self-hosted web UI for OpenAI-compatible endpoints, written in Python, themed
after Middle-earth.
| | |
|---|---|
| Stack | FastAPI + Jinja + htmx + a little Alpine |
| Build step | none — no Node, no npm, no CDN at runtime |
| Database | SQLite, schema synchronised additively at startup |
| Deployment | systemd unit + nginx vhost, one worker |
These are load-bearing. Dropping the no-build rule or moving off SQLite would
be a different project, not a refactor.
---
## Done
### Chat
- [x] Streaming replies over server-sent events
- [x] **Markdown renders progressively** — re-rendered whole every 100ms rather
than appending tokens, because a list or code fence is only correct once
its context exists
- [x] Syntax highlighting (Pygments), sanitised with nh3
- [x] **Generation runs in the background** — a task, not the request. Navigate
away, open another chat, close the tab: the reply keeps being written and
reattaching replays the whole state
- [x] **Stop** — the send button becomes Stop while writing; what arrived is kept
- [x] **Rewind** — edit one of your own turns and the conversation runs on from
there. Truncates rather than branching
- [x] Copy, regenerate, automatic chat titles
- [x] Chats created on first message, so an abandoned composer leaves nothing
- [x] **Unread indicator** — a green dot and a toast when a reply lands while
you were elsewhere
- [x] Folders, arbitrarily nested; deleting one keeps the chats inside it
### Models and reasoning
- [x] OpenAI-compatible connections with encrypted keys and model discovery
- [x] **Reasoning display**`reasoning_content` and inline `<think>` tags,
collapsed by default, labelled with how long it took, never replayed as
context
- [x] Model admin as a list plus a page per model; scales to hundreds
- [x] Ordering, pinning (a sidebar shortcut, *not* a reordering), instance
default, per-user default, images, capability flags
- [x] Custom model picker showing avatars, descriptions and capabilities
### Attachments
- [x] Drag, paste or pick images, PDFs and text files
- [x] Images downscaled and sent to vision models as content parts
- [x] PDF and text extracted at upload and placed in the prompt
- [x] Type decided by inspecting bytes, random names on disk, non-images served
as downloads with `nosniff`
- [x] No OCR: a scanned PDF says so rather than silently contributing nothing
### People
- [x] Accounts, argon2, revocable server-side sessions, self-service password
change
- [x] Users and groups with permissions that **union** rather than override
- [x] Model access restricted to chosen groups
- [x] Registration toggle, instance settings stored in the database
### Prompts
- [x] Three layers — instance, model, chat — with the most specific winning
**outright** rather than being concatenated
### Interface
- [x] Two themes (`moria`, `shire`) from one set of design tokens
- [x] Every control sized from `--control-h`, so rows line up by construction
- [x] Toasts and dialogs of our own; no `window.confirm` anywhere
- [x] Original SVG artwork generated from a single source
### Operations
- [x] Additive schema sync — new tables and columns applied at startup
- [x] `deploy/` — systemd unit and nginx templates, install and update scripts
---
## Not built yet
In the order they are likely to be worth doing.
### Tools — built-in, with admin settings
A tool registry, per-tool settings in the admin area, and tool-calling wired
through the chat loop. `Model.capabilities_json` already carries a `tools` flag
that nothing reads. The chat loop currently assumes one request produces one
reply; tool calling makes it a loop, which is the real work.
### Custom tools and MCP servers
An MCP client managing configured servers, their tools surfaced alongside the
built-in ones. Depends on the tool loop above.
### Agentic execution
Two modes, as originally specified:
- **local** — subprocess on the machine LLeMbas runs on
- **remote** — SSH connection profiles, with `shell.run` / `fs.read` / `fs.write`
Needs a confirmation model before it does anything. Note that the systemd unit
is deliberately only `ProtectSystem=full` rather than `strict` **because** of
this — revisit the hardening when the real filesystem needs are known.
### Image generation
Left until last from the start, as it needs heavy customisation. ComfyUI is
already running on this machine and is the obvious first target.
### Smaller things
- **OCR** for scanned PDFs
- **Conversation branching** — `Message.parent_id` exists unused; needs a UI for
choosing between versions, which is why rewind truncates for now
- **Web search** as a built-in tool
- **Chat export** (Markdown, JSON)
- **Archived chats** — the column exists, nothing surfaces it
- **Per-user quotas**
---
## Known limits
Worth knowing before they surprise someone.
**One worker.** The generation registry and the stop mechanism are in-process.
Running several workers needs that state in the database or a broker, because
the request following a reply would not necessarily land in the process writing
it.
**A restart abandons replies in flight.** Shutdown cancels them and keeps what
each had. There is no resume.
**Schema changes are additive only.** New tables and columns apply themselves;
renames, drops and retypes are manual against the SQLite file. `MANUAL_STEPS`
in `db/migrations.py` is where such a step gets recorded.
**Attachments live on disk, unreferenced files are swept at startup.** No
deduplication, no size quota.
**Unread is polled every 10 seconds.** A push channel would be more responsive
but means an always-on connection per tab for the sake of a green dot.
---
## Deliberate decisions
Recorded because each looks like an oversight until you know the reason.
- **No JavaScript build step.** Browser libraries are hash-pinned and committed.
A self-hosted tool should work offline and not report page views to a CDN.
- **Permissions union, never deny.** With denies, "why can this user not do X"
cannot be answered without simulating every group.
- **System prompts replace, never stack.** Two layers that disagree give the
model contradictory instructions and nobody can tell which is losing.
- **Rewind truncates, does not branch.** Branching needs a UI for choosing
between versions; "go back and try again from here" is what was asked for.
- **Pinning is a shortcut, not an ordering.** A picker whose order silently
differs from the admin screen is confusing.
- **Images only reach models marked `vision`.** Not graceful degradation: most
endpoints reject the entire request rather than ignoring an image part.
- **Markdown rendered server-side.** One code path produces the streamed and
the stored view, so they cannot disagree.
- **This repository is public.** Deployment hostnames, ports and paths stay out
of it; `deploy/` is templates, and the real values live in private notes.
+4
View File
@@ -41,6 +41,8 @@ runtime. Clone it, `pip install -e .`, run it.
- **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
- **Replies keep running in the background** — navigate away, open another
chat, close the tab; a green dot and a notification tell you when it lands
- **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
@@ -65,6 +67,8 @@ runtime. Clone it, `pip install -e .`, run it.
Built-in tools with admin settings · custom tools and MCP servers · agentic
execution (local and over SSH) · image generation · OCR for scanned PDFs.
See [PLAN.md](PLAN.md) for what is built, what is not, and why.
## Quick start
```bash
+82 -178
View File
@@ -3,8 +3,8 @@
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
@@ -18,37 +18,15 @@ from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.services import chat as chat_service
from lembas.services import files as files_service
from lembas.services import generation as generation_service
from lembas.services import sse
from lembas.services.llm.openai_client import (
LLMError,
delta_reasoning,
delta_text,
stream_chat,
)
from lembas.services.markdown import escape_text, render_markdown
from lembas.services.reasoning import REASONING, ReasoningSplitter
from lembas.web.templating import render, templates
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)
# 404 rather than 403 for someone else's chat: whether a given id exists is
@@ -106,9 +84,10 @@ async def start_chat(
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
chat_service.create_message(
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
generation_service.ensure(chat.id, assistant.id)
response = Response(status_code=status.HTTP_204_NO_CONTENT)
response.headers["HX-Redirect"] = f"/chat/{chat.id}"
@@ -120,6 +99,44 @@ async def start_chat(
# /start when the first message is actually sent.
@router.get("/unread")
async def unread_poll(db: Db, user: RequiredUser) -> Response:
"""Dots for the sidebar, and a toast for anything newly arrived.
Polled rather than pushed: a browser sitting on a different chat has no
open connection to the one that finished, and a second always-on channel
per tab is a lot of machinery for a green dot.
Returns out-of-band spans so only the dots change -- re-rendering the whole
sidebar would reset the folder open/closed state on every tick.
"""
chats = list(
db.scalars(
select(Chat).where(Chat.user_id == user.id, Chat.archived.is_(False))
)
)
fresh = [c for c in chats if c.unread and not c.unread_notified]
for chat in fresh:
chat.unread_notified = True
if fresh:
db.commit()
markup = "".join(
f'<span id="unread-{c.id}" class="unread-dot" hx-swap-oob="true"'
f'{"" if c.unread else " hidden"} title="New reply"></span>'
for c in chats
)
response = HTMLResponse(markup)
if fresh:
# HX-Trigger carries the toast; ui.js listens for it.
response.headers["HX-Trigger"] = json.dumps(
{"lembas:unread": {"titles": [c.title for c in fresh]}}
)
return response
@router.post("/{chat_id}/messages")
async def post_message(
request: Request,
@@ -150,6 +167,7 @@ async def post_message(
assistant_message = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
generation_service.ensure(chat.id, assistant_message.id)
# `user` is required by the shared message template, which renders both
# roles; without it the user bubble's initial blows up.
@@ -187,7 +205,7 @@ async def stream_message(
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
return StreamingResponse(
_generate(chat.id, message.id),
_follow(chat.id, message.id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
@@ -199,161 +217,47 @@ async def stream_message(
)
def _plain_text(content: str | list) -> str:
"""The text of a message payload, whether it is a string or content parts."""
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
).strip()
return ""
async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
"""Stream a generation that is running independently of this request.
This connection only *watches*. Closing it -- navigating away, opening
another chat -- leaves the reply being written, and reconnecting replays
the whole state immediately rather than starting over.
async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
"""Drive one completion and frame it as SSE.
Opens its own database session rather than using the request's: streaming
outlives the request handler, and the dependency-scoped session may already
be closed by the time the first token arrives.
Both `render` and `reasoning` carry the complete block each time rather
than a delta, which is what makes reattaching mid-reply work at all: a
follower arriving late has no earlier fragments to append to.
"""
accumulated: list[str] = []
thinking: list[str] = []
error: str | None = None
reasoning_ms = 0
with session_scope() as db:
chat = db.get(Chat, chat_id)
message = db.get(Message, message_id)
if chat is None or message is None:
yield sse.event("close", "")
return
first_user_text = ""
# Handles models that emit <think> tags inline in content rather than
# using the reasoning_content field.
splitter = ReasoningSplitter()
started = time.monotonic()
reasoning_started: float | None = None
last_render = 0.0
dirty = False
stopped = False
generation = generation_service.ensure(chat_id, message_id)
generation.followers += 1
seen = -1
try:
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
payload = chat_service.build_request(db, chat, upto=message)
# A multimodal turn's content is a list of parts, not a string, so
# the text has to be picked out before it can title a chat.
first_user_text = next(
(
_plain_text(m["content"])
for m in reversed(payload["messages"])
if m["role"] == ROLE_USER
),
"",
)
while True:
if generation.version != seen:
seen = generation.version
if generation.thinking:
yield sse.event("reasoning", escape_text(generation.thinking))
if generation.content:
yield sse.event("render", render_markdown(generation.text))
async for chunk in stream_chat(endpoint, payload):
# A dedicated reasoning field is unambiguous; take it as-is.
thought = delta_reasoning(chunk)
if thought:
if reasoning_started is None:
reasoning_started = time.monotonic()
thinking.append(thought)
yield sse.event("reasoning", escape_text(thought))
await asyncio.sleep(0)
text = delta_text(chunk)
if not text:
continue
for kind, piece in splitter.feed(text):
if kind == REASONING:
if reasoning_started is None:
reasoning_started = time.monotonic()
thinking.append(piece)
yield sse.event("reasoning", escape_text(piece))
else:
# First answer token ends the thinking phase.
if reasoning_started is not None and not reasoning_ms:
reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
accumulated.append(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
if generation.done:
break
# Polling rather than per-follower wakeups: the producer already
# works in RENDER_INTERVAL steps, so a short sleep is simpler and
# cannot drop a notification.
await asyncio.sleep(generation_service.RENDER_INTERVAL * 0.8)
finally:
generation.followers = max(0, generation.followers - 1)
for kind, piece in splitter.flush():
if kind == REASONING:
thinking.append(piece)
yield sse.event("reasoning", escape_text(piece))
else:
accumulated.append(piece)
except LLMError as exc:
error = exc.message
log.info("generation failed for chat %s: %s", chat_id, exc.message)
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
error = "Something went wrong while generating this reply."
log.exception("unexpected generation failure for chat %s: %s", chat_id, exc)
if reasoning_started is not None and not reasoning_ms:
# 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,
len(message.content),
len(message.reasoning),
time.monotonic() - started,
)
if not chat.title_generated and (accumulated or error):
chat.title = (
await chat_service.generate_title(
endpoint, model_id, first_user_text, message.content
)
if not error and first_user_text
else chat_service.fallback_title(first_user_text)
)
chat.title_generated = True
db.commit()
# The producer writes the message before marking itself done, so by here
# the row is authoritative and the final bubble can be rendered from it.
with session_scope() as db:
message = db.get(Message, message_id)
chat = db.get(Chat, chat_id)
if message is None or chat is None:
yield sse.event("close", "")
return
final_html = templates.get_template("chat/_message.html").render(
{
@@ -369,9 +273,7 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
},
}
)
title_html = templates.get_template("chat/_title_oob.html").render(
{"chat": chat}
)
title_html = templates.get_template("chat/_title_oob.html").render({"chat": chat})
yield sse.event("done", final_html + title_html)
yield sse.event("close", "")
@@ -485,9 +387,10 @@ async def edit_message(
db.delete(later)
db.commit()
chat_service.create_message(
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
generation_service.ensure(chat.id, assistant.id)
log.info("chat %s rewound to a message, %d discarded", chat.id, len(discarded))
return templates.TemplateResponse(
@@ -507,7 +410,7 @@ async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str
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)
generation_service.request_stop(message.id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -648,6 +551,7 @@ async def regenerate(
message.complete = False
message.model_id = chat.model_id
db.commit()
generation_service.ensure(chat.id, message.id)
return templates.TemplateResponse(
request,
+6
View File
@@ -117,6 +117,12 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
if chat is None or chat.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
# Opening the chat is what "read" means.
if chat.unread:
chat.unread = False
chat.unread_notified = False
db.commit()
messages = list(
db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
+6
View File
@@ -71,6 +71,12 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# A reply landed while nobody was watching this chat. Cleared when the chat
# is next opened. `unread_notified` stops the same arrival being announced
# on every poll.
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
folder: Mapped[Folder | None] = relationship(back_populates="chats")
messages: Mapped[list[Message]] = relationship(
back_populates="chat",
+6
View File
@@ -67,6 +67,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
log.info("data directory: %s", settings.data_dir.resolve())
yield
# Replies still being written are cancelled and persisted with whatever
# they have, rather than left as permanently unfinished rows.
from lembas.services.generation import shutdown as stop_generations
await stop_generations()
log.info("LLeMbas stopped")
+285
View File
@@ -0,0 +1,285 @@
"""Background reply generation.
Generation used to be driven by the SSE request: the browser opening the stream
was what produced the tokens, so navigating away cancelled the reply mid-
sentence. Here it runs as its own task instead, and the SSE endpoint merely
*follows* it. Closing the page, opening another chat, or starting a new one
leaves the answer being written; coming back attaches to it and immediately
receives everything produced so far.
The registry is in-process, which is right for the single-worker deployment
this ships with. Several workers would need the state in the database or a
broker, because the request that follows a generation would not necessarily
land in the process running it.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message
from lembas.db.session import session_scope
from lembas.services import chat as chat_service
from lembas.services.llm.openai_client import LLMError, delta_reasoning, delta_text, stream_chat
from lembas.services.reasoning import REASONING, ReasoningSplitter
log = logging.getLogger(__name__)
# How often the partial answer is offered to followers. Markdown is re-rendered
# whole each time -- a list or a code fence is only correct once its context
# exists -- so this trades a little work for formatting that appears as the
# model writes. 100ms is below the threshold where the eye reads it as stepping.
RENDER_INTERVAL = 0.1
# Finished generations linger so a follower attaching at the last moment still
# gets the final frames, then are pruned.
KEEP_FINISHED = timedelta(minutes=5)
@dataclass
class Generation:
"""The live state of one reply being written."""
chat_id: str
message_id: str
content: list[str] = field(default_factory=list)
reasoning: list[str] = field(default_factory=list)
reasoning_ms: int = 0
error: str = ""
stopped: bool = False
done: bool = False
# Bumped on every change. Followers compare against it rather than being
# woken individually: with a 100ms cadence a short poll is simpler than
# future bookkeeping, and cannot drop a wakeup.
version: int = 0
# Number of browsers currently watching. Decides whether a finished reply
# counts as unread.
followers: int = 0
finished_at: datetime | None = None
cancel: bool = False
def touch(self) -> None:
self.version += 1
@property
def text(self) -> str:
return "".join(self.content)
@property
def thinking(self) -> str:
return "".join(self.reasoning)
_RUNNING: dict[str, Generation] = {}
_TASKS: dict[str, asyncio.Task] = {}
def get(message_id: str) -> Generation | None:
return _RUNNING.get(message_id)
def request_stop(message_id: str) -> bool:
"""Ask a running generation to stop. Returns whether one was found."""
generation = _RUNNING.get(message_id)
if generation is None or generation.done:
return False
generation.cancel = True
return True
def _prune() -> None:
cutoff = datetime.now(UTC) - KEEP_FINISHED
for message_id, generation in list(_RUNNING.items()):
if generation.done and generation.finished_at and generation.finished_at < cutoff:
_RUNNING.pop(message_id, None)
_TASKS.pop(message_id, None)
def ensure(chat_id: str, message_id: str) -> Generation:
"""Start generating this reply if it is not already under way.
Idempotent, because more than one thing can ask for it: the route that
created the message, and any page load that finds the message unfinished.
"""
existing = _RUNNING.get(message_id)
if existing is not None:
return existing
_prune()
generation = Generation(chat_id=chat_id, message_id=message_id)
_RUNNING[message_id] = generation
_TASKS[message_id] = asyncio.create_task(_run(generation))
return generation
async def shutdown() -> None:
"""Stop every running generation, keeping what each has produced."""
for task in list(_TASKS.values()):
task.cancel()
for task in list(_TASKS.values()):
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def _run(generation: Generation) -> None:
"""Produce one reply, then persist it. Never raises into the task."""
splitter = ReasoningSplitter()
started = time.monotonic()
reasoning_started: float | None = None
question = ""
endpoint = model_id = None
needs_title = False
try:
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
message = db.get(Message, generation.message_id)
if chat is None or message is None:
generation.error = "That chat no longer exists."
return
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
payload = chat_service.build_request(db, chat, upto=message)
question = _question_from(payload)
needs_title = not chat.title_generated
async for chunk in stream_chat(endpoint, payload):
thought = delta_reasoning(chunk)
if thought:
if reasoning_started is None:
reasoning_started = time.monotonic()
generation.reasoning.append(thought)
generation.touch()
text = delta_text(chunk)
if text:
for kind, piece in splitter.feed(text):
if kind == REASONING:
if reasoning_started is None:
reasoning_started = time.monotonic()
generation.reasoning.append(piece)
else:
if reasoning_started is not None and not generation.reasoning_ms:
generation.reasoning_ms = int(
(time.monotonic() - reasoning_started) * 1000
)
generation.content.append(piece)
generation.touch()
if generation.cancel:
generation.stopped = True
break
# Let followers and other tasks run between chunks.
await asyncio.sleep(0)
for kind, piece in splitter.flush():
(generation.reasoning if kind == REASONING else generation.content).append(piece)
generation.touch()
except LLMError as exc:
generation.error = exc.message
log.info("generation failed for chat %s: %s", generation.chat_id, exc.message)
except asyncio.CancelledError:
# Shutdown, not a reader navigating away -- that no longer reaches here.
generation.stopped = True
raise
except Exception: # noqa: BLE001 - a task that dies silently is worse
generation.error = "Something went wrong while generating this reply."
log.exception("unexpected generation failure for chat %s", generation.chat_id)
finally:
if reasoning_started is not None and not generation.reasoning_ms:
generation.reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
# Naming the chat is a second, short completion, so it has to happen
# here rather than in the synchronous persist step below. Best-effort:
# a chat title is never worth surfacing an error for.
title = ""
if needs_title and question:
if generation.error or endpoint is None:
title = chat_service.fallback_title(question)
else:
with contextlib.suppress(Exception):
title = await chat_service.generate_title(
endpoint, model_id, question, generation.text
)
title = title or chat_service.fallback_title(question)
generation.done = True
generation.finished_at = datetime.now(UTC)
generation.touch()
_persist(generation, title, time.monotonic() - started)
def _question_from(payload: dict) -> str:
"""The last thing the user said, for auto-titling."""
for entry in reversed(payload.get("messages", [])):
if entry.get("role") != ROLE_USER:
continue
content = entry.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
).strip()
return ""
def _persist(generation: Generation, title: str, elapsed: float) -> None:
"""Write the finished reply, name the chat, and set the unread flag."""
try:
with session_scope() as db:
message = db.get(Message, generation.message_id)
chat = db.get(Chat, generation.chat_id)
if message is None or chat is None:
return
message.content = generation.text
message.reasoning = generation.thinking
message.reasoning_ms = generation.reasoning_ms
message.error = generation.error
message.stopped = generation.stopped
message.complete = True
if title and not chat.title_generated:
chat.title = title
chat.title_generated = True
# Nobody watching when it landed, so it is news. The chat page
# clears this when it is next opened.
if generation.followers == 0:
chat.unread = True
chat.unread_notified = False
db.commit()
log.debug(
"chat %s finished: %d chars, %d reasoning, %.1fs",
generation.chat_id,
len(message.content),
len(message.reasoning),
elapsed,
)
except Exception: # noqa: BLE001 - the task is ending either way
log.exception("could not persist generation for chat %s", generation.chat_id)
__all__ = [
"RENDER_INTERVAL",
"Generation",
"ROLE_ASSISTANT",
"ensure",
"get",
"request_stop",
"shutdown",
]
+36 -8
View File
@@ -208,8 +208,21 @@
the dots go, but Stop must stay reachable until the stream ends. */
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
.msg__stop { color: var(--ink-muted); }
.msg__stop:hover { color: var(--danger); border-color: var(--danger); }
.composer__stop {
background: var(--danger);
border-color: var(--danger);
color: var(--ink-inverse);
}
.composer__stop:hover:not(:disabled) {
background: var(--danger-hover);
border-color: var(--danger-hover);
}
.composer__stop-square {
width: 0.7rem;
height: 0.7rem;
border-radius: 2px;
background: currentColor;
}
.msg__note {
display: flex;
@@ -480,25 +493,28 @@
.attachments {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
/* inline-block, not block: as a block the anchor filled the column and drew its
border at full width around a narrow image. Now the frame is the picture. */
/* No frame: an attachment is a picture, and a border around it only ever drew
at the wrong width. The anchor shrink-wraps its image rather than filling the
column, and the width/height attributes on the <img> are overridden so a
small image is shown at its own size instead of being stretched. */
.attachments__image {
display: inline-block;
display: inline-flex;
max-width: 100%;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
line-height: 0;
}
.attachments__image img {
display: block;
max-width: min(22rem, 100%);
max-height: 20rem;
width: auto;
height: auto;
max-width: min(22rem, 100%);
max-height: 20rem;
object-fit: contain;
}
.attachments__doc {
display: flex;
@@ -549,3 +565,15 @@
/* Alpine sets x-cloak until it has initialised; without this, collapsed
folders flash open on every page load. */
[x-cloak] { display: none !important; }
/* --- Unread indicator ------------------------------------------------------ */
.unread-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--radius-full);
background: var(--success);
flex: none;
/* A ring so it stays visible against the active row's lighter background. */
box-shadow: 0 0 0 2px color-mix(in srgb, var(--success) 25%, transparent);
}
.unread-dot[hidden] { display: none; }
+85
View File
@@ -378,3 +378,88 @@
options[next].focus();
});
})();
/*
Unread replies.
The sidebar polls /api/chats/unread; the response carries out-of-band spans
for the dots and, when something has just landed, an HX-Trigger asking for a
toast. Announcing it here rather than server-side keeps the wording and the
timing in one place.
*/
document.addEventListener("lembas:unread", function (event) {
var titles = (event.detail && event.detail.titles) || [];
if (!titles.length || !window.lembas || !window.lembas.notify) return;
var message = titles.length === 1
? "Reply ready in “" + titles[0] + "”"
: titles.length + " chats have new replies";
window.lembas.notify(message, { kind: "success", timeout: 6000 });
});
/*
Send becomes Stop while a reply is being written.
The composer and the streaming bubble are far apart in the document, so the
link between them is made here: whenever the thread changes, look for a
message that is still streaming and point the button at it. A MutationObserver
rather than htmx events, because the bubble is replaced by an SSE swap that
does not always surface as one.
*/
(function () {
"use strict";
function streamingMessage() {
var live = document.querySelector(".msg[sse-connect]");
if (!live) return null;
var id = live.id.replace(/^msg-/, "");
var chat = (live.getAttribute("sse-connect") || "").match(/\/api\/chats\/([^/]+)\//);
return chat ? { messageId: id, chatId: chat[1] } : null;
}
function sync() {
var form = document.querySelector(".composer__form");
if (!form) return;
var send = form.querySelector('[type="submit"]');
var stop = form.querySelector("[data-composer-stop]");
var active = streamingMessage();
if (active) {
if (send) send.hidden = true;
if (!stop) {
stop = document.createElement("button");
stop.type = "button";
stop.className = "btn btn--icon composer__btn composer__stop";
stop.setAttribute("data-composer-stop", "");
stop.setAttribute("aria-label", "Stop generating");
stop.title = "Stop generating";
stop.innerHTML = '<span class="composer__stop-square"></span>';
stop.addEventListener("click", function () {
var target = streamingMessage();
if (!target) return;
stop.disabled = true;
fetch(
"/api/chats/" + target.chatId + "/messages/" + target.messageId + "/stop",
{ method: "POST", credentials: "same-origin" }
).catch(function () { stop.disabled = false; });
});
(send ? send.parentNode : form).appendChild(stop);
}
stop.hidden = false;
stop.disabled = false;
} else {
if (send) send.hidden = false;
if (stop) stop.hidden = true;
}
}
function watch() {
var thread = document.getElementById("thread");
if (!thread) return;
new MutationObserver(sync).observe(thread, { childList: true, subtree: true });
sync();
}
document.addEventListener("DOMContentLoaded", watch);
document.body && document.body.addEventListener("htmx:afterSwap", sync);
})();
+2 -5
View File
@@ -106,13 +106,10 @@
rather than snapping into place at the end. #}
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
sse-swap="render" hx-swap="innerHTML"></div>
{# No stop button here: the composer's send button becomes Stop while a
reply is being written, which is where the hand already is. #}
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
<button class="btn btn--sm msg__stop" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stop"
hx-swap="none">
{{ icon("x", "icon--sm") }} Stop
</button>
</div>
{% elif message.reasoning and not message.error %}
{# Collapsed once finished: the answer is what the reader came for, and
@@ -8,6 +8,9 @@
<a class="nav-item__link" href="/chat/{{ chat_item.id }}">
{{ icon("chat", "icon--sm") }}
<span class="nav-item__label" id="chat-link-label-{{ chat_item.id }}">{{ chat_item.title }}</span>
{# Toggled out of band by the unread poll; see /api/chats/unread. #}
<span id="unread-{{ chat_item.id }}" class="unread-dot"
{{ '' if chat_item.unread else 'hidden' }} title="New reply"></span>
</a>
<span class="nav-item__actions">
<button class="btn btn--icon btn--sm" type="button"
@@ -27,6 +27,12 @@
</div>
{% endif %}
{# Every 10s, refresh the unread dots and announce anything that finished
while this page was showing something else. Out-of-band spans only, so the
folder tree keeps its open/closed state. #}
<div hidden hx-get="/api/chats/unread" hx-trigger="every 10s"
hx-swap="none"></div>
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
{% if pinned_models and can.get("chat.create") %}
{# Shortcuts to start a chat with a particular model. These link rather than
+126 -9
View File
@@ -355,12 +355,10 @@ def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
# --- Stopping a stream -------------------------------------------------------
def test_stopping_marks_the_message_and_keeps_what_arrived(
client: TestClient, db, registered, make_chat
):
def test_stopping_asks_the_generation_to_stop(client: TestClient, db, registered, make_chat):
"""A half-written answer the reader chose to cut short is still worth
having; discarding it would be a surprise."""
from lembas.api.chats import _CANCELLED
from lembas.services import generation as generation_service
_add_connection(db)
chat_id = make_chat()
@@ -370,8 +368,11 @@ def test_stopping_marks_the_message_and_keeps_what_arrived(
assert client.post(
f"/api/chats/{chat_id}/messages/{message.id}/stop"
).status_code == 204
assert message.id in _CANCELLED
_CANCELLED.discard(message.id)
running = generation_service.get(message.id)
# The endpoint points at 127.0.0.1:1, so the task may already have failed
# and finished; either way the request must be accepted, not error.
assert running is None or running.cancel or running.done
def test_stopping_someone_elses_message_is_refused(client: TestClient, db, registered, make_chat):
@@ -391,12 +392,16 @@ def test_stopping_someone_elses_message_is_refused(client: TestClient, db, regis
).status_code == 404
def test_the_streaming_bubble_offers_a_stop_button(client: TestClient, db, registered, make_chat):
def test_the_streaming_bubble_carries_the_sse_connection(
client: TestClient, db, registered, make_chat
):
"""Stop lives on the composer's send button now, and the JS finds the
running message through this attribute."""
_add_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
assert "/stop" in response.text
assert "msg__stop" in response.text
assert "sse-connect" in response.text
assert f"/api/chats/{chat_id}/messages/" in response.text
def test_the_streaming_bubble_renders_markdown_not_raw_tokens(
@@ -501,3 +506,115 @@ def test_cancelling_an_edit_restores_the_bubble(client: TestClient, db, register
page = client.get(f"/api/chats/{chat_id}/messages/{user_message.id}/cancel-edit").text
assert "unchanged" in page
assert "edit-form" not in page
# --- Background generation ---------------------------------------------------
def test_sending_launches_the_generation_immediately(
client: TestClient, db, registered, make_chat
):
"""The reply is produced by a task, not by the browser watching it. That is
what lets you navigate away without cutting it off."""
from lembas.services import generation as generation_service
_add_connection(db)
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
assert generation_service.get(message.id) is not None
def test_starting_a_chat_launches_the_generation(client: TestClient, db, registered):
from lembas.services import generation as generation_service
_add_connection(db)
client.post("/api/chats/start", data={"content": "hi"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
assert generation_service.get(message.id) is not None
def test_asking_twice_does_not_start_a_second_generation(
client: TestClient, db, registered, make_chat
):
"""A page load finding an unfinished reply must attach, not restart."""
from lembas.services import generation as generation_service
_add_connection(db)
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
first = generation_service.get(message.id)
assert generation_service.ensure(chat_id, message.id) is first
# --- Unread -------------------------------------------------------------------
def test_the_unread_poll_reports_dots(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.unread = True
db.commit()
response = client.get("/api/chats/unread")
assert f'id="unread-{chat_id}"' in response.text
assert "hidden" not in response.text
assert "lembas:unread" in response.headers.get("HX-Trigger", "")
def test_an_arrival_is_announced_once(client: TestClient, db, registered, make_chat):
"""Otherwise the same reply would toast every ten seconds forever."""
_add_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.unread = True
db.commit()
assert "HX-Trigger" in client.get("/api/chats/unread").headers
assert "HX-Trigger" not in client.get("/api/chats/unread").headers
def test_opening_a_chat_marks_it_read(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.unread = True
db.commit()
client.get(f"/chat/{chat_id}")
db.expire_all()
assert db.get(Chat, chat_id).unread is False
def test_a_read_chat_reports_a_hidden_dot(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
response = client.get("/api/chats/unread")
assert f'id="unread-{chat_id}"' in response.text
assert "hidden" in response.text
def test_the_unread_poll_only_sees_your_own_chats(client: TestClient, db, registered, make_chat):
_add_connection(db)
mine = make_chat()
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
assert mine not in client.get("/api/chats/unread").text
def test_the_sidebar_shows_an_unread_dot(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
chat = db.get(Chat, chat_id)
chat.unread = True
db.commit()
# Rendered on another page, so the dot is visible while looking elsewhere.
page = client.get("/chat").text
assert f'id="unread-{chat_id}" class="unread-dot"' in page
assert 'hx-get="/api/chats/unread"' in page