diff --git a/CLAUDE.md b/CLAUDE.md index 38b62f4..bc6e950 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d557b12 --- /dev/null +++ b/PLAN.md @@ -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 `` 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. diff --git a/README.md b/README.md index d650571..2daebd0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index a465ddb..b83c3d4 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -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'' + 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,162 +217,48 @@ 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 + generation = generation_service.ensure(chat_id, message_id) + generation.followers += 1 + seen = -1 + try: + 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)) + + 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) + + # 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: - chat = db.get(Chat, chat_id) message = db.get(Message, message_id) - if chat is None or message is None: + chat = db.get(Chat, chat_id) + if message is None or chat is None: yield sse.event("close", "") return - first_user_text = "" - # Handles models that emit 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: - 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 - ), - "", - ) - - 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 - break - - 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() - final_html = templates.get_template("chat/_message.html").render( { "message": message, @@ -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, diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 1617c4f..f16b976 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -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) diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py index 9a80b18..b1a3144 100644 --- a/src/lembas/db/models/chat.py +++ b/src/lembas/db/models/chat.py @@ -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", diff --git a/src/lembas/main.py b/src/lembas/main.py index cf3d7d5..c80c5bf 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -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") diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py new file mode 100644 index 0000000..b3d2976 --- /dev/null +++ b/src/lembas/services/generation.py @@ -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", +] diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 8f896be..51519d1 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -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 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; } diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 837cb45..37032fe 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -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 = ''; + 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); +})(); diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html index 9481959..c4a9653 100644 --- a/src/lembas/web/templates/chat/_message.html +++ b/src/lembas/web/templates/chat/_message.html @@ -106,13 +106,10 @@ rather than snapping into place at the end. #}
+ {# No stop button here: the composer's send button becomes Stop while a + reply is being written, which is where the hand already is. #}
-
{% elif message.reasoning and not message.error %} {# Collapsed once finished: the answer is what the reader came for, and diff --git a/src/lembas/web/templates/partials/_chat_link.html b/src/lembas/web/templates/partials/_chat_link.html index 4e1145a..673ce79 100644 --- a/src/lembas/web/templates/partials/_chat_link.html +++ b/src/lembas/web/templates/partials/_chat_link.html @@ -8,6 +8,9 @@ {{ icon("chat", "icon--sm") }} {{ chat_item.title }} + {# Toggled out of band by the unread poll; see /api/chats/unread. #} +