None of these fails loudly and two of them correct themselves if you reload, which is why five were found by reading rather than by anybody reporting them. The reply that lost its author is the one worth knowing about: the frame that replaces a bubble when a reply lands was looking the models up as nobody, and "no user" answers "no models" rather than "all models" -- so every finished reply swapped the model's avatar for the plain mark and put the instance's name where the model's should be, until the next page load put it back. Beside it: a concurrency quota enforced on two of the six paths that start a reply, including neither of the two most used; a custom theme whose success and warning colours moved the text and left the background behind; a phone shell sized to one viewport inside a document sized to another, which is the reported scroll past the bottom of Settings; a whole conversation's Markdown rendered on every page load and read by nothing; a skip guard inert since it was written; and an endpoint nothing has ever called. The scroll fix folded five near-identical scroller rules into one, which is also where the containment they were all missing now lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
"""Messages: one conversation per person, read backwards on demand.
|
|
|
|
The page is the ordinary chat shell with two differences: it opens on the most
|
|
recent turns rather than on all of them, and above them sits a sentinel that
|
|
fetches the page before whenever it is scrolled into view.
|
|
|
|
That sentinel is the mirror of `GET /api/chats/{id}/tail`, which polls forwards,
|
|
and it keeps the same four properties for the same reasons — most of all
|
|
answering **204 to a cursor it cannot place** rather than falling back to "the
|
|
oldest hundred", which would prepend a block the page already holds.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Request, Response, status
|
|
|
|
from lembas.api.deps import Db, RequiredUser
|
|
from lembas.api.pages import _chat_context, sidebar_context
|
|
from lembas.db.models import Message, Schedule
|
|
from lembas.services import messages as messages_service
|
|
from lembas.services import schedules as schedules_service
|
|
from lembas.services.schedule import clock
|
|
from lembas.services.schedule import rule as rule_service
|
|
from lembas.web.templating import render
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["messages"])
|
|
|
|
|
|
@router.get("/messages")
|
|
async def messages_page(request: Request, db: Db, user: RequiredUser):
|
|
conversation = messages_service.for_user(db, user)
|
|
live = messages_service.live_messages(db, conversation)
|
|
|
|
# The schedules that post in here, listed beside the conversation because
|
|
# this is where somebody would look for them -- a schedule whose output
|
|
# arrives in this thread and whose controls are two pages away is one nobody
|
|
# will find when they want to stop it.
|
|
posting = list(
|
|
db.scalars(
|
|
schedules_service.visible(user)
|
|
.where(Schedule.target == "messages")
|
|
.order_by(Schedule.created_at.desc())
|
|
)
|
|
)
|
|
zone = clock.zone_for(user)
|
|
|
|
return render(
|
|
request,
|
|
"messages/index.html",
|
|
{
|
|
"chat": conversation,
|
|
"messages": live,
|
|
"compacted": [],
|
|
"inherited_prompt": "",
|
|
"inherited_from": "",
|
|
"more_before": bool(live) and messages_service.has_more_before(
|
|
db, conversation, live[0]
|
|
),
|
|
"oldest_id": live[0].id if live else "",
|
|
"schedules": [
|
|
{
|
|
"row": row,
|
|
"summary": rule_service.describe(row.rule_json or {}, zone=zone),
|
|
}
|
|
for row in posting
|
|
],
|
|
**_chat_context(db, user, conversation),
|
|
**sidebar_context(db, user),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/api/messages/history")
|
|
async def messages_history(
|
|
request: Request, db: Db, user: RequiredUser, before: str = ""
|
|
) -> Response:
|
|
"""The page of turns immediately before `before`, oldest first.
|
|
|
|
204 rather than a fallback whenever the cursor cannot be placed: an absent
|
|
one, one from another chat, one belonging to a message that has gone. The
|
|
alternative -- answering with the oldest page -- would prepend a block the
|
|
reader is already looking at, and a duplicated transcript is something only
|
|
a reload can reconcile.
|
|
"""
|
|
conversation = messages_service.for_user(db, user)
|
|
cursor = db.get(Message, before) if before else None
|
|
if cursor is None or cursor.chat_id != conversation.id:
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
page = messages_service.older_than(db, conversation, cursor)
|
|
if not page:
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
from lembas.web.templating import templates
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"messages/_history.html",
|
|
{
|
|
"messages": page,
|
|
"more_before": messages_service.has_more_before(db, conversation, page[0]),
|
|
"oldest_id": page[0].id,
|
|
# `render()` injects `user` and friends; `TemplateResponse` does
|
|
# not, and `chat/_message.html` dereferences both `user` and `chat`
|
|
# -- the same reason the SSE path passes them by hand. Missing
|
|
# either is a 500 on scroll and nothing at all on the page that
|
|
# rendered fine.
|
|
"user": user,
|
|
"chat": conversation,
|
|
**_chat_context(db, user, conversation),
|
|
},
|
|
)
|