"""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 KIND_CHAT, KINDS, Chat, Folder, KnowledgeBase, Message, User from lembas.security import permissions from lembas.services import audio as audio_service from lembas.services import canvas as canvas_service from lembas.services import chat as chat_service from lembas.services import compaction as compaction_service from lembas.services import settings_store from lembas.services import suggestions as suggestions_service from lembas.services.library import documents as documents_service 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, "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}, # Offered in the chat settings panel so a conversation can be pointed at # particular bases. Empty when the reader has none, and the panel then # shows nothing rather than an empty control. "knowledge_bases": ( list( db.scalars( documents_service.visible_bases(db, user).order_by(KnowledgeBase.name) ) ) if permissions.has(db, user, "library.use") else [] ), "attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [], # The three a reasoning model understands. From the service so the # command, the control and the request builder cannot disagree about # what is a valid effort. "efforts": chat_service.EFFORTS, # What the picker shows, and what `build_request` will send. One # resolver so the two cannot disagree. "resolved_effort": chat_service.resolved_effort(chat) if chat else "", **_scope_context(db, user, chat), **_agent_context(db, user, chat), **audio_service.template_flags(db, user), } def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict: """What this chat may use, for the menu that narrows it. Only for an existing chat: there is no row to write to before one exists, and a menu whose choices went nowhere would be worse than no menu. The families listed are the ones actually offered *right now*, so the menu never shows a switch for something the model, the reader's permissions or the instance has already ruled out -- turning that on would do nothing, since `resolve_tools` applies this after the gates. """ from lembas.services import tool_labels from lembas.services import tools as tools_service from lembas.services.library import skills as skills_service if chat is None: return {"scope_families": [], "scope_skills": [], "scope_allow": []} off = tools_service.scoped_off(chat) skills_off = tools_service.scoped_skills_off(chat) # Gates rather than tool names: `notes` is one switch, not five, which is # the same reasoning the per-model capability checkboxes carry. seen: dict[str, str] = {} for tool in tools_service.resolve_tools(db, chat, user).defs: seen.setdefault(tools_service.gate_of(tool.family), tool.name) # Anything already switched off is absent from the offered set, so it has to # be put back or there would be no way to turn it on again. for gate in off: seen.setdefault(gate, "") families = [ { "gate": gate, "label": _GATE_LABELS.get(gate) or tool_labels.label_for(example) or gate, "on": gate not in off, } for gate, example in sorted(seen.items()) ] skills = [] if permissions.has(db, user, "library.use"): skills = [ { "name": skill.name, "description": skill.description, "on": skill.name not in skills_off, } for skill in skills_service.enabled_for(db, user) ] for name in sorted(skills_off): if name not in {s["name"] for s in skills}: skills.append({"name": name, "description": "", "on": False}) # What this chat has been told to stop asking about. Shown so the list # cannot grow invisibly: every entry is one click of "Always allow this" on # a card, and a standing permission nobody can see is one nobody can revoke. return { "scope_families": families, "scope_skills": skills, "scope_allow": list(tools_service.scoped_allow(chat)), } # What a gate is called in the menu. A gate covers several tools, so no single # tool's label is the right name for it. _GATE_LABELS = { "web_search": "Web search", "fetch": "Fetching pages", "knowledge": "Your knowledge library", "notes": "Notes", "memory": "Memory", "skills": "Skills", "ask": "Asking you questions", "agent": "Running commands", "custom": "Custom tools", "mcp": "MCP servers", } def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict: """What the composer and the chat header need to know about agent chats. `agent_profiles` is empty unless every one of the conditions holds -- the feature is on, the reader may run commands, and they have a usable connection -- which is what makes the picker appear only when choosing it would lead anywhere. """ from lembas.db.models import SshProfile from lembas.services.agent import policy as agent_policy profiles: list[SshProfile] = [] if settings_store.agents(db).get("enabled") and permissions.has(db, user, "tools.agent"): profiles = list( db.scalars( select(SshProfile) .where(SshProfile.owner_id == user.id, SshProfile.enabled.is_(True)) .order_by(SshProfile.name) ) ) current = None if chat is not None and chat.ssh_profile_id: current = db.get(SshProfile, chat.ssh_profile_id) if current is not None and current.owner_id != user.id: current = None return { "agent_profiles": profiles, "agent_profile": current, "agent_modes": [ (m, agent_policy.MODE_LABELS[m], agent_policy.MODE_HINTS[m]) for m in agent_policy.MODES ], "terminal_enabled": _terminal_enabled(db, user, chat, current), # Whether this chat could have background jobs at all. Not whether it # has any -- that is what the chip's own request answers, five seconds # later, off the request path. A chip that can never show anything is a # chip that only takes room in a row this codebase has already had to # fight to keep on one line. "jobs_enabled": _jobs_enabled(db, user, chat, current), # Any chat that exists. Deliberately not gated the way the terminal is: # half the canvas's sources -- notes, skills, this chat's attachments, # its own scratch document -- need no machine at all, so the terminal's # total gate would remove a working feature because one source is # unavailable. Absent on the new-chat screen for the reason the scope # menu is: there is no row yet to hang a tab on. "canvas_enabled": chat is not None, # And whether it may *also* reach project files. Re-derived server-side # on every canvas request; this flag only decides what the panel offers. "canvas_agent": canvas_service.agent_ready(db, user, chat) is not None, } def _jobs_enabled(db: DBSession, user: User, chat: Chat | None, profile) -> bool: """Whether background jobs are possible in this chat. The same shape as `_terminal_enabled` and for the same reason, but keyed on `background_enabled` rather than on `terminal_enabled` and on `tools.agent` rather than `agent.terminal` -- somebody who may have a model run commands here may see which of them are still running. It is not a second permission, because there is no action here the agent tools do not already grant. """ from lembas.db.models import KIND_AGENT from lembas.services.agent import ssh as ssh_service if chat is None or chat.kind != KIND_AGENT or profile is None: return False if not permissions.has(db, user, "tools.agent"): return False values = settings_store.agents(db) if not values.get("enabled") or not values.get("background_enabled"): return False return ssh_service.available() == "" def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) -> bool: """Whether this chat can offer a shell of its own. Every condition, not a subset: the button loads 280KB of terminal and opens a socket, so one that cannot work is worse than none. `ssh.available()` is in here because an instance that installed LLeMbas without the `ssh` extra would otherwise render a button whose only outcome is an error frame. """ from lembas.db.models import KIND_AGENT from lembas.services.agent import ssh as ssh_service if chat is None or chat.kind != KIND_AGENT or profile is None: return False if not permissions.has(db, user, "agent.terminal"): return False values = settings_store.agents(db) if not values.get("enabled") or not values.get("terminal_enabled", True): return False return ssh_service.available() == "" def sidebar_kind(user: User) -> str: """Which side of the sidebar's switch this user last chose. One resolver, because the page, the fragment route and the switch's own pressed state all have to agree about it. Anything unrecognised -- an older release's value, a hand-edited row -- reads as ordinary chats rather than showing an empty sidebar nobody can explain. """ chosen = (user.settings_json or {}).get("sidebar_kind") return chosen if chosen in KINDS else KIND_CHAT def sidebar_context(db: DBSession, user: User) -> dict: """Folder tree plus the chats that belong to no folder. Public because every page carrying the chat sidebar needs it, which now includes the library. Only root folders are queried; children come through the relationship and render recursively in the template. Everything is narrowed to one `Chat.kind`. A folder the filter has emptied is dropped here rather than in the template, so the "Folders" heading cannot appear above nothing -- the same reason `visible_chats` moved off the template in the first place. `shown_in` is what draws that line: a folder that was empty to begin with is kept, on both sides. """ # With the switch absent the sidebar goes back to showing everything, rather # than to one side of a fork nobody can move. An administrator turning agent # chats off would otherwise strand whoever last left the switch on Agents in # a sidebar that is empty with no way out of it. split = permissions.has(db, user, "agent.ssh") and bool( settings_store.agents(db).get("enabled") ) kind = sidebar_kind(user) if split else "" folders = [ folder for folder in db.scalars( select(Folder) .where(Folder.user_id == user.id, Folder.parent_id.is_(None)) .order_by(Folder.position, Folder.name) ) if folder.shown_in(kind) ] narrowed = select(Chat).where( Chat.user_id == user.id, Chat.folder_id.is_(None), Chat.archived.is_(False), Chat.temporary.is_(False), ) if kind: narrowed = narrowed.where(Chat.kind == kind) unfiled = list( db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc())) ) return { "folders": folders, "unfiled_chats": unfiled, # The shortcuts at the top of the sidebar. Here rather than in # `_chat_context`, where they used to be, for two reasons: they are # sidebar content and the fragment route that re-renders the sidebar has # only this, and the library and connections pages carry the sidebar # without ever calling `_chat_context` -- so the shortcuts simply were # not there on any of them. The picker lists every model in the # administrator's order, pinned or not; pinning is not ordering. "pinned_models": [m for m in chat_service.available_models(db, user) if m.pinned], "sidebar_kind": kind, # Whether the switch is worth showing at all. A two-way switch with one # useful side is worse than no switch: it offers a view that is empty by # construction and cannot be made otherwise. "sidebar_split": split, "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 = "", temporary: bool = False, kind: str = "", folder: 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. `?temporary=1` is the same idea for the temporary flag: it lives in the URL rather than in JavaScript, so it survives a reload and can be bookmarked. `?kind=agent` is how the sidebar's Agent side opens a new chat already on that side -- a preselection like the other two, not a decision: the kind is still chosen on the screen and still fixed only when the first message is sent. `?folder=` is the same again, and is what "New chat here" on a folder row posts: the chat is filed there, and `_new_chat` fills in whatever the folder seeds and the screen left empty. """ context = _chat_context(db, user, None) # Somebody else's folder id in the URL is ignored rather than refused. It # would only ever get there by hand, and an error page holding a composer # hostage over a bad query string helps nobody. starting_folder = db.get(Folder, folder) if folder else None if starting_folder is not None and starting_folder.user_id != user.id: starting_folder = None # A folder that fixes the kind picks the fork, unless the URL already said. if not kind and starting_folder is not None: kind = starting_folder.kind # 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) # The folder's own model, ahead of the reader's default and behind an # explicit `?model=`. Same order `_new_chat` applies, so the picker shows # the model the chat is actually going to be created with -- which matters, # because the composer decides from it whether to warn about images. if preselected is None and starting_folder is not None and starting_folder.model_id: preselected = next( (m for m in context["models"] if m.model_id == starting_folder.model_id), 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, "starting_temporary": temporary, "starting_kind": kind if kind in KINDS else KIND_CHAT, "starting_folder": starting_folder, "suggestions": suggestions_service.visible(db), **sidebar_context(db, user), }, ) @router.get("/folders/{folder_id}") async def folder_settings(request: Request, db: Db, user: RequiredUser, folder_id: str): """What a folder hands to the chats started inside it. A page rather than a row that expands, following the admin convention: a form per row in a tree that nests eight deep would be unusable, and the sidebar is the one part of the application that has to stay scannable. Guarded by `folder.manage`, the same permission the whole folder router carries -- editing a folder's system prompt is managing a folder, and a page that renders for somebody whose save is going to 403 is a trap. """ if not permissions.has(db, user, "folder.manage"): raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot manage folders.") folder = db.get(Folder, folder_id) if folder is None or folder.user_id != user.id: raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.") return render( request, "folders/edit.html", { "folder": folder, "chat": None, "models": chat_service.available_models(db, user), **_agent_context(db, user, None), **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() everything = list( db.scalars( select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at) ) ) # Summarised turns are kept and still rendered, behind a divider -- they # have only stopped being part of the request. compacted, messages = compaction_service.split(db, chat, everything) # Empty, and kept only so `_thread.html` and the four handlers that render a # bubble keep one signature between them. An assistant turn is rendered from # its steps now (`message_steps`, a Jinja global), which is what lets a # reply's prose sit either side of the tool call it surrounded rather than # arriving as one block at the bottom. Nothing reads this for an assistant # message any more; `library/note_detail.html` has its own. bodies: dict[str, str] = {} # 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. # # This mirrors `chat_service.effective_system_prompt` and has to keep # mirroring it, layer for layer and in the same order -- a panel naming the # wrong source is worse than one naming none, because it is believed. inherited, inherited_from = "", "" folder_prompt = chat_service.folder_system_prompt(db, chat) current = next( (m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None ) if folder_prompt: inherited, inherited_from = folder_prompt, "folder" elif current is not None and (current.system_prompt or "").strip(): inherited, inherited_from = current.system_prompt.strip(), "model" else: 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, "compacted": compacted, "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 from lembas.services.library import memories as memories_service 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, "memories": memories_service.all_for(db, user), "memory_limit": memories_service.MAX_MEMORY_CHARS, **context, **sidebar_context(db, user), }, )