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 lembas create-admin # create or promote an admin
pytest # 230 tests, ~9s pytest # 230 tests, ~9s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100) ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate all SVG artwork python scripts/build_artwork.py # regenerate all SVG artwork
python scripts/fetch_vendor.py # verify vendored JS against the lockfile 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 by that user, so a forged id cannot pull in someone else's file. Abandoned ones
are swept at startup. are swept at startup.
**Markdown renders progressively, server-side.** The stream sends `render` **Generation is a background task; the SSE endpoint only follows it.**
events carrying the whole answer re-rendered from Markdown, at most every `services/generation.py` owns the work and the registry; `api/chats.py:_follow`
`RENDER_INTERVAL`, swapped with `innerHTML`. Appending raw tokens instead would watches a `Generation` and streams what it sees. Closing the connection does
mean formatting only appearing at the end -- a list or code fence is only NOT stop the reply -- that was the old behaviour and it cut answers off when
correct once its context exists, so partial output must be re-rendered whole the reader navigated away. Any route that creates an assistant placeholder must
rather than appended to. also call `generation.ensure()`.
**Stopping a stream is an in-process set.** `api/chats.py:_CANCELLED` holds **Stream frames carry whole blocks, not deltas.** Both `render` and `reasoning`
message ids the reader asked to stop; the generator checks it between chunks. send the complete text each time. That is what makes reattaching mid-reply
Correct for the single-worker deployment this ships with; multiple workers work: a follower arriving late has no earlier fragments to append to. It also
would need it in the database or a broker. 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 **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 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 - **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 - **Stop and rewind** — cut a reply short and keep what arrived, or edit an
earlier message and run the conversation on from there 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 - **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 downscaled and sent to vision models; PDF and text content is extracted and
put in the prompt 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 Built-in tools with admin settings · custom tools and MCP servers · agentic
execution (local and over SSH) · image generation · OCR for scanned PDFs. 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 ## Quick start
```bash ```bash
+82 -178
View File
@@ -3,8 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status 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.security import permissions
from lembas.services import chat as chat_service from lembas.services import chat as chat_service
from lembas.services import files as files_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 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.markdown import escape_text, render_markdown
from lembas.services.reasoning import REASONING, ReasoningSplitter
from lembas.web.templating import render, templates from lembas.web.templating import render, templates
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chats", tags=["chats"]) 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: def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
chat = db.get(Chat, chat_id) chat = db.get(Chat, chat_id)
# 404 rather than 403 for someone else's chat: whether a given id exists is # 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) user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if file_ids: if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id) 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 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 = Response(status_code=status.HTTP_204_NO_CONTENT)
response.headers["HX-Redirect"] = f"/chat/{chat.id}" response.headers["HX-Redirect"] = f"/chat/{chat.id}"
@@ -120,6 +99,44 @@ async def start_chat(
# /start when the first message is actually sent. # /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") @router.post("/{chat_id}/messages")
async def post_message( async def post_message(
request: Request, request: Request,
@@ -150,6 +167,7 @@ async def post_message(
assistant_message = chat_service.create_message( assistant_message = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id 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 # `user` is required by the shared message template, which renders both
# roles; without it the user bubble's initial blows up. # 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.") raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
return StreamingResponse( return StreamingResponse(
_generate(chat.id, message.id), _follow(chat.id, message.id),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache, no-transform", "Cache-Control": "no-cache, no-transform",
@@ -199,161 +217,47 @@ async def stream_message(
) )
def _plain_text(content: str | list) -> str: async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
"""The text of a message payload, whether it is a string or content parts.""" """Stream a generation that is running independently of this request.
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 ""
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]: Both `render` and `reasoning` carry the complete block each time rather
"""Drive one completion and frame it as SSE. than a delta, which is what makes reattaching mid-reply work at all: a
follower arriving late has no earlier fragments to append to.
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.
""" """
accumulated: list[str] = [] generation = generation_service.ensure(chat_id, message_id)
thinking: list[str] = [] generation.followers += 1
error: str | None = None seen = -1
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
try: try:
endpoint, model_id = chat_service.resolve_endpoint(db, chat) while True:
payload = chat_service.build_request(db, chat, upto=message) if generation.version != seen:
# A multimodal turn's content is a list of parts, not a string, so seen = generation.version
# the text has to be picked out before it can title a chat. if generation.thinking:
first_user_text = next( yield sse.event("reasoning", escape_text(generation.thinking))
( if generation.content:
_plain_text(m["content"]) yield sse.event("render", render_markdown(generation.text))
for m in reversed(payload["messages"])
if m["role"] == ROLE_USER
),
"",
)
async for chunk in stream_chat(endpoint, payload): if generation.done:
# 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
break 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(): # The producer writes the message before marking itself done, so by here
if kind == REASONING: # the row is authoritative and the final bubble can be rendered from it.
thinking.append(piece) with session_scope() as db:
yield sse.event("reasoning", escape_text(piece)) message = db.get(Message, message_id)
else: chat = db.get(Chat, chat_id)
accumulated.append(piece) if message is None or chat is None:
yield sse.event("close", "")
except LLMError as exc: return
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()
final_html = templates.get_template("chat/_message.html").render( 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( title_html = templates.get_template("chat/_title_oob.html").render({"chat": chat})
{"chat": chat}
)
yield sse.event("done", final_html + title_html) yield sse.event("done", final_html + title_html)
yield sse.event("close", "") yield sse.event("close", "")
@@ -485,9 +387,10 @@ async def edit_message(
db.delete(later) db.delete(later)
db.commit() db.commit()
chat_service.create_message( assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id 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)) log.info("chat %s rewound to a message, %d discarded", chat.id, len(discarded))
return templates.TemplateResponse( 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: if message is None or message.chat_id != chat.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.") 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) return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -648,6 +551,7 @@ async def regenerate(
message.complete = False message.complete = False
message.model_id = chat.model_id message.model_id = chat.model_id
db.commit() db.commit()
generation_service.ensure(chat.id, message.id)
return templates.TemplateResponse( return templates.TemplateResponse(
request, 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: if chat is None or chat.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.") 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( messages = list(
db.scalars( db.scalars(
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at) 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) pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived: 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") folder: Mapped[Folder | None] = relationship(back_populates="chats")
messages: Mapped[list[Message]] = relationship( messages: Mapped[list[Message]] = relationship(
back_populates="chat", 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("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
log.info("data directory: %s", settings.data_dir.resolve()) log.info("data directory: %s", settings.data_dir.resolve())
yield 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") 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. */ the dots go, but Stop must stay reachable until the stream ends. */
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; } .msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
.msg__stop { color: var(--ink-muted); } .composer__stop {
.msg__stop:hover { color: var(--danger); border-color: var(--danger); } 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 { .msg__note {
display: flex; display: flex;
@@ -480,25 +493,28 @@
.attachments { .attachments {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start;
gap: var(--sp-2); gap: var(--sp-2);
margin-bottom: var(--sp-2); margin-bottom: var(--sp-2);
} }
/* inline-block, not block: as a block the anchor filled the column and drew its /* No frame: an attachment is a picture, and a border around it only ever drew
border at full width around a narrow image. Now the frame is the picture. */ 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 { .attachments__image {
display: inline-block; display: inline-flex;
max-width: 100%; max-width: 100%;
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
border: 1px solid var(--border);
line-height: 0; line-height: 0;
} }
.attachments__image img { .attachments__image img {
display: block; display: block;
max-width: min(22rem, 100%);
max-height: 20rem;
width: auto; width: auto;
height: auto; height: auto;
max-width: min(22rem, 100%);
max-height: 20rem;
object-fit: contain;
} }
.attachments__doc { .attachments__doc {
display: flex; display: flex;
@@ -549,3 +565,15 @@
/* Alpine sets x-cloak until it has initialised; without this, collapsed /* Alpine sets x-cloak until it has initialised; without this, collapsed
folders flash open on every page load. */ folders flash open on every page load. */
[x-cloak] { display: none !important; } [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(); 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. #} rather than snapping into place at the end. #}
<div class="msg__body msg__body--live" id="stream-{{ message.id }}" <div class="msg__body msg__body--live" id="stream-{{ message.id }}"
sse-swap="render" hx-swap="innerHTML"></div> 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"> <div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span> <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> </div>
{% elif message.reasoning and not message.error %} {% elif message.reasoning and not message.error %}
{# Collapsed once finished: the answer is what the reader came for, and {# 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 }}"> <a class="nav-item__link" href="/chat/{{ chat_item.id }}">
{{ icon("chat", "icon--sm") }} {{ icon("chat", "icon--sm") }}
<span class="nav-item__label" id="chat-link-label-{{ chat_item.id }}">{{ chat_item.title }}</span> <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> </a>
<span class="nav-item__actions"> <span class="nav-item__actions">
<button class="btn btn--icon btn--sm" type="button" <button class="btn btn--icon btn--sm" type="button"
@@ -27,6 +27,12 @@
</div> </div>
{% endif %} {% 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"> <nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
{% if pinned_models and can.get("chat.create") %} {% if pinned_models and can.get("chat.create") %}
{# Shortcuts to start a chat with a particular model. These link rather than {# 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 ------------------------------------------------------- # --- Stopping a stream -------------------------------------------------------
def test_stopping_marks_the_message_and_keeps_what_arrived( def test_stopping_asks_the_generation_to_stop(client: TestClient, db, registered, make_chat):
client: TestClient, db, registered, make_chat
):
"""A half-written answer the reader chose to cut short is still worth """A half-written answer the reader chose to cut short is still worth
having; discarding it would be a surprise.""" having; discarding it would be a surprise."""
from lembas.api.chats import _CANCELLED from lembas.services import generation as generation_service
_add_connection(db) _add_connection(db)
chat_id = make_chat() chat_id = make_chat()
@@ -370,8 +368,11 @@ def test_stopping_marks_the_message_and_keeps_what_arrived(
assert client.post( assert client.post(
f"/api/chats/{chat_id}/messages/{message.id}/stop" f"/api/chats/{chat_id}/messages/{message.id}/stop"
).status_code == 204 ).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): 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 ).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) _add_connection(db)
chat_id = make_chat() chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"}) response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
assert "/stop" in response.text assert "sse-connect" in response.text
assert "msg__stop" in response.text assert f"/api/chats/{chat_id}/messages/" in response.text
def test_the_streaming_bubble_renders_markdown_not_raw_tokens( 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 page = client.get(f"/api/chats/{chat_id}/messages/{user_message.id}/cancel-edit").text
assert "unchanged" in page assert "unchanged" in page
assert "edit-form" not 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