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. #}
+