47f2cff640
Reported: a pinned model always opened an ordinary chat, even with Agents selected in the sidebar. They now carry `&kind=agent` with the switch -- a preselection like `?model=` itself, so the new-chat screen still decides and nothing is fixed until the first message is sent. They sit above the tree the switch swaps, so this is the same shape as the New chat button a few commits ago and gets the same treatment: their own partial, arriving out of band. The group is rendered even when nothing is pinned, because a block that vanished when the last model was unpinned would leave that fragment with nowhere to land -- and htmx says nothing at all when a target is missing, which is the silent failure this codebase keeps cataloguing. `.nav-group--pinned:empty` stops the empty one taking room. Chasing it turned up something else. The shortcuts came from `_chat_context`, which only the chat pages build -- so the library, connections, settings and folder pages carried the sidebar without them. A shortcut that is there on one page and gone on the next. They come from `sidebar_context` now, where they belong: it is sidebar content, and it is what the fragment route has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
572 lines
23 KiB
Python
572 lines
23 KiB
Python
"""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.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,
|
|
"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),
|
|
# 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 _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)
|
|
|
|
# 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 everything
|
|
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.
|
|
#
|
|
# 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),
|
|
},
|
|
)
|