ca3e4fd04f
**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>
193 lines
6.4 KiB
Python
193 lines
6.4 KiB
Python
"""Full-page routes: the chat shell and the user's own settings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, status
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session as DBSession
|
|
|
|
from lembas.api.deps import Db, RequiredUser
|
|
from lembas.db.models import Chat, Folder, Message, User
|
|
from lembas.security import permissions
|
|
from lembas.services import chat as chat_service
|
|
from lembas.services.markdown import render_markdown
|
|
from lembas.web.templating import render
|
|
|
|
router = APIRouter(tags=["pages"])
|
|
|
|
|
|
def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
|
"""Model lists and permissions every chat page needs.
|
|
|
|
Pinned and unpinned are split here rather than in the template so the
|
|
picker's optgroups stay a plain loop.
|
|
"""
|
|
models = chat_service.available_models(db, user)
|
|
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
|
|
return {
|
|
"models": models,
|
|
# For the sidebar shortcuts only. The picker lists `models` in the
|
|
# administrator's order, pinned or not.
|
|
"pinned_models": [m for m in models if m.pinned],
|
|
"current_model": current,
|
|
# Assistant bubbles show the avatar of the model that wrote them, which
|
|
# may not be the model the chat is set to now. Keyed by model_id, the
|
|
# denormalised value stored on each message.
|
|
"models_by_id": {m.model_id: m for m in models},
|
|
}
|
|
|
|
|
|
def _sidebar_context(db: DBSession, user: User) -> dict:
|
|
"""Folder tree plus the chats that belong to no folder.
|
|
|
|
Only root folders are queried; children come through the relationship and
|
|
render recursively in the template.
|
|
"""
|
|
folders = list(
|
|
db.scalars(
|
|
select(Folder)
|
|
.where(Folder.user_id == user.id, Folder.parent_id.is_(None))
|
|
.order_by(Folder.position, Folder.name)
|
|
)
|
|
)
|
|
unfiled = list(
|
|
db.scalars(
|
|
select(Chat)
|
|
.where(
|
|
Chat.user_id == user.id,
|
|
Chat.folder_id.is_(None),
|
|
Chat.archived.is_(False),
|
|
)
|
|
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
|
)
|
|
)
|
|
return {
|
|
"folders": folders,
|
|
"unfiled_chats": unfiled,
|
|
"can": permissions.resolve(db, user),
|
|
}
|
|
|
|
|
|
@router.get("/")
|
|
async def home(user: RequiredUser):
|
|
return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.get("/chat")
|
|
async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""):
|
|
"""A composer with no chat behind it yet.
|
|
|
|
`?model=` preselects one, which is how the pinned shortcuts work without
|
|
creating a row for a chat that may never be sent.
|
|
"""
|
|
context = _chat_context(db, user, None)
|
|
|
|
# Fall back to the same choice a new chat would make -- the user's default,
|
|
# then the instance default, then first in order. Using models[0] here
|
|
# instead would show a model the chat is not going to use, which matters:
|
|
# the composer decides from it whether to warn that images will be dropped.
|
|
preselected = next((m for m in context["models"] if m.model_id == model), None)
|
|
if preselected is None:
|
|
chosen = chat_service.default_model(db, user)
|
|
if chosen is not None:
|
|
preselected = next(
|
|
(m for m in context["models"] if m.model_id == chosen[0]), None
|
|
)
|
|
if preselected is None and context["models"]:
|
|
preselected = context["models"][0]
|
|
|
|
return render(
|
|
request,
|
|
"chat/index.html",
|
|
{
|
|
"chat": None,
|
|
"messages": [],
|
|
"bodies": {},
|
|
**context,
|
|
"current_model": preselected,
|
|
**_sidebar_context(db, user),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/chat/{chat_id}")
|
|
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
|
chat = db.get(Chat, chat_id)
|
|
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)
|
|
)
|
|
)
|
|
|
|
# Markdown is rendered once here rather than in the template so the same
|
|
# helper produces the page and the streamed final frame -- one code path,
|
|
# no chance of the two disagreeing.
|
|
bodies = {
|
|
message.id: render_markdown(message.content)
|
|
for message in messages
|
|
if message.role == "assistant" and message.content
|
|
}
|
|
|
|
# What the chat would use if its own prompt were empty, so the settings
|
|
# panel can show it as placeholder text rather than leaving the user to
|
|
# guess what "inherited" means.
|
|
inherited, inherited_from = "", ""
|
|
current = next(
|
|
(m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None
|
|
)
|
|
if current is not None and (current.system_prompt or "").strip():
|
|
inherited, inherited_from = current.system_prompt.strip(), "model"
|
|
else:
|
|
from lembas.services import settings_store
|
|
|
|
instance_prompt = (settings_store.get(db, "system_prompt") or "").strip()
|
|
if instance_prompt:
|
|
inherited, inherited_from = instance_prompt, "instance"
|
|
|
|
return render(
|
|
request,
|
|
"chat/index.html",
|
|
{
|
|
"chat": chat,
|
|
"messages": messages,
|
|
"bodies": bodies,
|
|
"inherited_prompt": inherited,
|
|
"inherited_from": inherited_from,
|
|
**_chat_context(db, user, chat),
|
|
**_sidebar_context(db, user),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/settings")
|
|
async def settings_page(
|
|
request: Request,
|
|
db: Db,
|
|
user: RequiredUser,
|
|
error: str = "",
|
|
saved: str = "",
|
|
):
|
|
# error/saved arrive as query parameters because the password form redirects
|
|
# back here: a POST that re-rendered in place would re-submit on refresh.
|
|
return render(
|
|
request,
|
|
"settings.html",
|
|
{
|
|
"chat": None,
|
|
"error": error,
|
|
"saved": saved,
|
|
**_chat_context(db, user, None),
|
|
**_sidebar_context(db, user),
|
|
},
|
|
)
|