"""Full-page routes: the chat shell and the user's own settings.""" from __future__ import annotations from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi.responses import FileResponse, JSONResponse, 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 audio as audio_service from lembas.services import chat as chat_service from lembas.services import settings_store from lembas.services.markdown import render_markdown from lembas.web.templating import STATIC_DIR, render router = APIRouter(tags=["pages"]) # Matches --bg for each theme in tokens.css. Duplicated here because the # manifest is JSON read by the operating system before any stylesheet exists; # there is nowhere for a CSS variable to resolve. THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"} 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}, **audio_service.template_flags(db, user), } 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) # --- Installing as an app ----------------------------------------------------- # All three routes below are deliberately unauthenticated. A browser fetches a # manifest and a service worker outside any page's session, and an offline page # has by definition no server to ask who is looking at it. @router.get("/manifest.webmanifest", include_in_schema=False) async def manifest(db: Db) -> Response: """The web app manifest. A route rather than a static file because the name is an instance setting, and an installed app showing "LLeMbas" when the instance is called something else would be wrong on the one screen that is hardest to correct: the launcher. """ name = settings_store.get(db, "instance_name") or "LLeMbas" return JSONResponse( { "id": "/", "name": name, "short_name": name[:12], "description": "A web UI for your language models.", "start_url": "/chat", "scope": "/", "display": "standalone", "background_color": THEME_COLOUR["moria"], "theme_color": THEME_COLOUR["moria"], "icons": [ {"src": "/static/img/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"}, {"src": "/static/img/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"}, {"src": "/static/img/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}, ], }, media_type="application/manifest+json", ) @router.get("/sw.js", include_in_schema=False) async def service_worker() -> Response: """The service worker, served from the root. A worker may only control pages at or below the path it was served from, so one delivered by the /static mount would have scope /static/js/ and control nothing. Serving it here is simpler than the Service-Worker-Allowed header that would be needed otherwise. no-store because a stale worker is a worker that keeps serving a stale cache: the one file in the application that must never be held onto. """ return FileResponse( STATIC_DIR / "js" / "sw.js", media_type="text/javascript", headers={"Cache-Control": "no-store"}, ) @router.get("/offline", include_in_schema=False) async def offline(request: Request) -> Response: return render(request, "offline.html", {}) @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 = "", ): from lembas.api.audio import available_voices context = _chat_context(db, user, None) # Fetched here rather than by the template so a speech server that is down # leaves the page renderable, with the reason beside an empty list. voices, voice_error = await available_voices(context["audio"]) # 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, "voices": voices, "voice_error": voice_error, **context, **_sidebar_context(db, user), }, )