"""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), }, )