Fix bulk actions, create chats lazily, rework the UI
Seven reported problems.
**Bulk model actions 404'd.** /admin/models/{model_id} was registered
before /admin/models/bulk, and FastAPI matches in registration order, so
"bulk" was parsed as a model id. Moved above the parameterised route,
with a comment saying why, and a regression test.
**Empty chats piled up.** There is now no endpoint that creates one.
"New chat" is a link to /chat, which renders a composer with no row
behind it, and POST /api/chats/start writes the chat together with its
first message. Opening one and walking away leaves nothing.
**Pinning meant two different things.** The picker is now always in the
administrator's position order; pinned models get shortcuts in the chat
sidebar and nothing else. A picker whose order silently differs from the
admin screen is just confusing.
**Model images were missing in chat.** Assistant bubbles now show the
avatar of the model that actually wrote the turn -- which is not always
the model the chat is set to now -- falling back to the LLeMbas mark.
The picker shows it too.
**No global or per-model system prompt.** Three layers now: instance
(Admin -> General), model (Admin -> Models), chat. Precedence, not
concatenation: most specific wins outright. Stacking them reads well in
a settings screen and badly in practice, because two layers that
disagree give the model contradictory instructions and nobody can tell
which is losing. The chat panel shows the inherited prompt as
placeholder text so "leave empty to inherit" is not a guess.
**Alignment and button sizing.** Added --control-h and friends to
tokens.css; every button, input and select takes its height from them,
so a mixed row is flush by construction rather than by per-instance
nudging. Icon buttons are square at that height. Added .btn-row,
.card__header/.card__footer and .grid so pages stop carrying inline
styles, and moved every admin page onto them.
**Settings needed structure.** The user settings page is now tabbed
(Account / Models / Appearance / Security) using radio inputs and
sibling selectors -- no JavaScript, and the browser keeps the chosen tab
across a re-render.
Caught while checking: the chat.css surgery had deleted the attachment,
chip and dropzone rules. Restored, and there is now a check that every
literal class used in a template has a CSS rule.
197 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,10 @@ runtime. Clone it, `pip install -e .`, run it.
|
|||||||
**Working now**
|
**Working now**
|
||||||
|
|
||||||
- **Chats** — streaming replies, Markdown with server-side syntax highlighting,
|
- **Chats** — streaming replies, Markdown with server-side syntax highlighting,
|
||||||
copy and regenerate, automatic chat titles, per-chat system prompt and
|
copy and regenerate, automatic chat titles. Chats are created when you send
|
||||||
sampling settings
|
the first message, so an abandoned one never clutters the sidebar
|
||||||
|
- **System prompts** — instance-wide, per-model and per-chat, with the most
|
||||||
|
specific winning outright
|
||||||
- **Reasoning display** — thinking from reasoning models streams into its own
|
- **Reasoning display** — thinking from reasoning models streams into its own
|
||||||
collapsible block, labelled with how long it took, and is never replayed as
|
collapsible block, labelled with how long it took, and is never replayed as
|
||||||
context
|
context
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async def save_general(
|
|||||||
user: AdminUser,
|
user: AdminUser,
|
||||||
instance_name: str = Form("LLeMbas"),
|
instance_name: str = Form("LLeMbas"),
|
||||||
allow_signup: bool = Form(False),
|
allow_signup: bool = Form(False),
|
||||||
|
system_prompt: str = Form(""),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Save instance settings.
|
"""Save instance settings.
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ async def save_general(
|
|||||||
{
|
{
|
||||||
"instance_name": instance_name.strip()[:120] or "LLeMbas",
|
"instance_name": instance_name.strip()[:120] or "LLeMbas",
|
||||||
"allow_signup": allow_signup,
|
"allow_signup": allow_signup,
|
||||||
|
"system_prompt": system_prompt.strip()[:8000],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
|
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
|
||||||
|
|||||||
@@ -59,12 +59,43 @@ async def models_page(request: Request, db: Db, user: AdminUser, saved: str = ""
|
|||||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||||
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
|
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
|
||||||
"default_model": settings_store.get(db, "default_model") or "",
|
"default_model": settings_store.get(db, "default_model") or "",
|
||||||
|
"instance_prompt": settings_store.get(db, "system_prompt") or "",
|
||||||
"capabilities": CAPABILITIES,
|
"capabilities": CAPABILITIES,
|
||||||
"saved": saved,
|
"saved": saved,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Registered BEFORE /{model_id}: FastAPI matches in registration order, so
|
||||||
|
# with the parameterised route first, "bulk" is captured as a model id and
|
||||||
|
# the handler 404s on a model that does not exist.
|
||||||
|
@router.post("/admin/models/bulk")
|
||||||
|
async def bulk_models(
|
||||||
|
db: Db, user: AdminUser, action: str = Form(...), model_ids: list[str] = Form(default=[])
|
||||||
|
) -> Response:
|
||||||
|
"""Enable or disable several models at once.
|
||||||
|
|
||||||
|
A freshly refreshed connection can advertise dozens of models; turning them
|
||||||
|
off one at a time is not a reasonable way to spend an afternoon.
|
||||||
|
"""
|
||||||
|
models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
|
||||||
|
for model in models:
|
||||||
|
if action == "enable":
|
||||||
|
model.enabled = True
|
||||||
|
elif action == "disable":
|
||||||
|
model.enabled = False
|
||||||
|
elif action == "public":
|
||||||
|
model.public = True
|
||||||
|
model.groups = []
|
||||||
|
elif action == "private":
|
||||||
|
model.public = False
|
||||||
|
db.commit()
|
||||||
|
_renumber(db)
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/models?saved={len(models)}+model(s)+updated.", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/models/{model_id}")
|
@router.post("/admin/models/{model_id}")
|
||||||
async def update_model(
|
async def update_model(
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -72,6 +103,7 @@ async def update_model(
|
|||||||
model_id: str,
|
model_id: str,
|
||||||
display_name: str = Form(""),
|
display_name: str = Form(""),
|
||||||
description: str = Form(""),
|
description: str = Form(""),
|
||||||
|
system_prompt: str = Form(""),
|
||||||
enabled: bool = Form(False),
|
enabled: bool = Form(False),
|
||||||
pinned: bool = Form(False),
|
pinned: bool = Form(False),
|
||||||
public: bool = Form(False),
|
public: bool = Form(False),
|
||||||
@@ -82,6 +114,7 @@ async def update_model(
|
|||||||
|
|
||||||
model.display_name = display_name.strip()[:300]
|
model.display_name = display_name.strip()[:300]
|
||||||
model.description = description.strip()[:2000]
|
model.description = description.strip()[:2000]
|
||||||
|
model.system_prompt = system_prompt.strip()[:8000]
|
||||||
model.enabled = enabled
|
model.enabled = enabled
|
||||||
model.pinned = pinned
|
model.pinned = pinned
|
||||||
model.public = public
|
model.public = public
|
||||||
@@ -165,33 +198,6 @@ async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response
|
|||||||
return RedirectResponse("/admin/models?saved=Image+removed.", status_code=303)
|
return RedirectResponse("/admin/models?saved=Image+removed.", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/models/bulk")
|
|
||||||
async def bulk_models(
|
|
||||||
db: Db, user: AdminUser, action: str = Form(...), model_ids: list[str] = Form(default=[])
|
|
||||||
) -> Response:
|
|
||||||
"""Enable or disable several models at once.
|
|
||||||
|
|
||||||
A freshly refreshed connection can advertise dozens of models; turning them
|
|
||||||
off one at a time is not a reasonable way to spend an afternoon.
|
|
||||||
"""
|
|
||||||
models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
|
|
||||||
for model in models:
|
|
||||||
if action == "enable":
|
|
||||||
model.enabled = True
|
|
||||||
elif action == "disable":
|
|
||||||
model.enabled = False
|
|
||||||
elif action == "public":
|
|
||||||
model.public = True
|
|
||||||
model.groups = []
|
|
||||||
elif action == "private":
|
|
||||||
model.public = False
|
|
||||||
db.commit()
|
|
||||||
_renumber(db)
|
|
||||||
return RedirectResponse(
|
|
||||||
f"/admin/models?saved={len(models)}+model(s)+updated.", status_code=303
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Serving model images ----------------------------------------------------
|
# --- Serving model images ----------------------------------------------------
|
||||||
@router.get("/uploads/models/{filename}")
|
@router.get("/uploads/models/{filename}")
|
||||||
async def model_image(user: RequiredUser, filename: str) -> Response:
|
async def model_image(user: RequiredUser, filename: str) -> Response:
|
||||||
|
|||||||
+64
-6
@@ -42,9 +42,18 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
|||||||
return chat
|
return chat
|
||||||
|
|
||||||
|
|
||||||
@router.post("", dependencies=[Depends(require_permission("chat.create"))])
|
def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str = "") -> Chat:
|
||||||
async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response:
|
"""Create a chat row, resolving which model it should use."""
|
||||||
chosen = chat_service.default_model(db, user)
|
chosen = None
|
||||||
|
if model_id:
|
||||||
|
match = next(
|
||||||
|
(m for m in chat_service.available_models(db, user) if m.model_id == model_id), None
|
||||||
|
)
|
||||||
|
if match is not None:
|
||||||
|
chosen = (match.model_id, match.connection_id)
|
||||||
|
if chosen is None:
|
||||||
|
chosen = chat_service.default_model(db, user)
|
||||||
|
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
folder_id=folder_id or None,
|
folder_id=folder_id or None,
|
||||||
@@ -53,14 +62,48 @@ async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) ->
|
|||||||
)
|
)
|
||||||
db.add(chat)
|
db.add(chat)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start", dependencies=[Depends(require_permission("chat.create"))])
|
||||||
|
async def start_chat(
|
||||||
|
db: Db,
|
||||||
|
user: RequiredUser,
|
||||||
|
content: str = Form(""),
|
||||||
|
file_ids: list[str] = Form(default=[]),
|
||||||
|
folder_id: str = Form(""),
|
||||||
|
model_id: str = Form(""),
|
||||||
|
) -> Response:
|
||||||
|
"""Create a chat from its first message.
|
||||||
|
|
||||||
|
Chats are made here rather than by a "New chat" button so that an opened-
|
||||||
|
and-abandoned chat never exists: the row appears only once there is
|
||||||
|
something in it. The reply then streams the same way as any other, because
|
||||||
|
/chat/{id} renders the unfinished assistant message with its sse-connect.
|
||||||
|
"""
|
||||||
|
content = content.strip()
|
||||||
|
if not content and not file_ids:
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
chat = _new_chat(db, user, folder_id=folder_id, model_id=model_id)
|
||||||
|
|
||||||
|
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||||
|
if file_ids:
|
||||||
|
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
|
||||||
|
chat_service.create_message(
|
||||||
|
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
||||||
|
)
|
||||||
|
|
||||||
# HX-Redirect rather than a swap: a new chat is a new URL, and the address
|
|
||||||
# bar has to follow so the chat can be reloaded or bookmarked.
|
|
||||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
response.headers["HX-Redirect"] = f"/chat/{chat.id}"
|
response.headers["HX-Redirect"] = f"/chat/{chat.id}"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# There is deliberately no route that creates an empty chat. Starting one is
|
||||||
|
# navigation to /chat (optionally ?model=...), and the row is written by
|
||||||
|
# /start when the first message is actually sent.
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{chat_id}/messages")
|
@router.post("/{chat_id}/messages")
|
||||||
async def post_message(
|
async def post_message(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -103,6 +146,9 @@ async def post_message(
|
|||||||
"assistant_message": assistant_message,
|
"assistant_message": assistant_message,
|
||||||
"chat": chat,
|
"chat": chat,
|
||||||
"user": user,
|
"user": user,
|
||||||
|
"models_by_id": {
|
||||||
|
m.model_id: m for m in chat_service.available_models(db, user)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,6 +327,9 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
|||||||
# template shares both roles, and a missing `user` would only
|
# template shares both roles, and a missing `user` would only
|
||||||
# blow up on whichever branch is not being exercised here.
|
# blow up on whichever branch is not being exercised here.
|
||||||
"user": db.get(User, chat.user_id),
|
"user": db.get(User, chat.user_id),
|
||||||
|
"models_by_id": {
|
||||||
|
m.model_id: m for m in chat_service.available_models(db, None)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
title_html = templates.get_template("chat/_title_oob.html").render(
|
title_html = templates.get_template("chat/_title_oob.html").render(
|
||||||
@@ -432,7 +481,16 @@ async def regenerate(
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"chat/_message.html",
|
"chat/_message.html",
|
||||||
{"request": request, "message": message, "chat": chat, "body_html": "", "user": user},
|
{
|
||||||
|
"request": request,
|
||||||
|
"message": message,
|
||||||
|
"chat": chat,
|
||||||
|
"body_html": "",
|
||||||
|
"user": user,
|
||||||
|
"models_by_id": {
|
||||||
|
m.model_id: m for m in chat_service.available_models(db, user)
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+39
-8
@@ -25,15 +25,16 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
|||||||
"""
|
"""
|
||||||
models = chat_service.available_models(db, user)
|
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
|
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
|
||||||
pinned = [m for m in models if m.pinned]
|
|
||||||
return {
|
return {
|
||||||
"models": models,
|
"models": models,
|
||||||
"pinned_models": pinned,
|
# For the sidebar shortcuts only. The picker lists `models` in the
|
||||||
# Excludes the pinned ones: they already have their own optgroup, and
|
# administrator's order, pinned or not.
|
||||||
# listing a model twice gives the <select> two options with the same
|
"pinned_models": [m for m in models if m.pinned],
|
||||||
# value, both marked selected.
|
|
||||||
"other_models": [m for m in models if not m.pinned] if pinned else models,
|
|
||||||
"current_model": current,
|
"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},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -74,14 +75,26 @@ async def home(user: RequiredUser):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/chat")
|
@router.get("/chat")
|
||||||
async def chat_index(request: Request, db: Db, user: RequiredUser):
|
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)
|
||||||
|
preselected = next(
|
||||||
|
(m for m in context["models"] if m.model_id == model), None
|
||||||
|
) or (context["models"][0] if context["models"] else None)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"chat/index.html",
|
"chat/index.html",
|
||||||
{
|
{
|
||||||
"chat": None,
|
"chat": None,
|
||||||
"messages": [],
|
"messages": [],
|
||||||
**_chat_context(db, user, None),
|
"bodies": {},
|
||||||
|
**context,
|
||||||
|
"current_model": preselected,
|
||||||
**_sidebar_context(db, user),
|
**_sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -108,6 +121,22 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
|
|||||||
if message.role == "assistant" and message.content
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"chat/index.html",
|
"chat/index.html",
|
||||||
@@ -115,6 +144,8 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
|
|||||||
"chat": chat,
|
"chat": chat,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"bodies": bodies,
|
"bodies": bodies,
|
||||||
|
"inherited_prompt": inherited,
|
||||||
|
"inherited_from": inherited_from,
|
||||||
**_chat_context(db, user, chat),
|
**_chat_context(db, user, chat),
|
||||||
**_sidebar_context(db, user),
|
**_sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
# image cannot become a request to a third party on every page render.
|
# image cannot become a request to a third party on every page render.
|
||||||
image_path: Mapped[str] = mapped_column(String(300), default="")
|
image_path: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
|
||||||
|
# Applied to chats using this model when the chat has none of its own.
|
||||||
|
# See services.chat.effective_system_prompt for the precedence.
|
||||||
|
system_prompt: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
# Endpoints do not reliably advertise capabilities, so these are admin
|
# Endpoints do not reliably advertise capabilities, so these are admin
|
||||||
# overrides. Recognised keys: vision, tools, reasoning.
|
# overrides. Recognised keys: vision, tools, reasoning.
|
||||||
capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||||
|
|||||||
@@ -129,6 +129,32 @@ def message_payload(message: Message, *, vision: bool) -> dict[str, Any]:
|
|||||||
return {"role": message.role, "content": parts}
|
return {"role": message.role, "content": parts}
|
||||||
|
|
||||||
|
|
||||||
|
def effective_system_prompt(db: DBSession, chat: Chat) -> str:
|
||||||
|
"""The system prompt a chat actually runs with.
|
||||||
|
|
||||||
|
Three layers, most specific wins outright:
|
||||||
|
|
||||||
|
chat > model > instance
|
||||||
|
|
||||||
|
Precedence rather than concatenation. Stacking them reads well in a
|
||||||
|
settings screen and badly in practice: the moment two layers disagree the
|
||||||
|
model gets contradictory instructions and nobody can tell which one is
|
||||||
|
losing. With precedence, "why is it behaving like this" has one answer.
|
||||||
|
"""
|
||||||
|
from lembas.services import settings_store
|
||||||
|
|
||||||
|
if chat.system_prompt.strip():
|
||||||
|
return chat.system_prompt.strip()
|
||||||
|
|
||||||
|
model = db.scalar(
|
||||||
|
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
|
||||||
|
)
|
||||||
|
if model is not None and (model.system_prompt or "").strip():
|
||||||
|
return model.system_prompt.strip()
|
||||||
|
|
||||||
|
return (settings_store.get(db, "system_prompt") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def build_messages(
|
def build_messages(
|
||||||
db: DBSession, chat: Chat, *, upto: Message | None = None, vision: bool = False
|
db: DBSession, chat: Chat, *, upto: Message | None = None, vision: bool = False
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -138,8 +164,9 @@ def build_messages(
|
|||||||
everything after it.
|
everything after it.
|
||||||
"""
|
"""
|
||||||
payload: list[dict[str, Any]] = []
|
payload: list[dict[str, Any]] = []
|
||||||
if chat.system_prompt.strip():
|
system = effective_system_prompt(db, chat)
|
||||||
payload.append({"role": ROLE_SYSTEM, "content": chat.system_prompt.strip()})
|
if system:
|
||||||
|
payload.append({"role": ROLE_SYSTEM, "content": system})
|
||||||
|
|
||||||
history = db.scalars(
|
history = db.scalars(
|
||||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||||
@@ -209,18 +236,23 @@ def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
|
|||||||
if instance_default and instance_default in by_id:
|
if instance_default and instance_default in by_id:
|
||||||
return instance_default, by_id[instance_default].connection_id
|
return instance_default, by_id[instance_default].connection_id
|
||||||
|
|
||||||
# Pinned models sort first, matching what the picker shows at the top.
|
# First in the administrator's ordering. Pinning is a sidebar shortcut, not
|
||||||
ordered = sorted(reachable, key=lambda m: (not m.pinned, m.position, m.model_id))
|
# a reordering, so it deliberately does not influence this.
|
||||||
chosen = ordered[0]
|
chosen = sorted(reachable, key=lambda m: (m.position, m.model_id))[0]
|
||||||
return chosen.model_id, chosen.connection_id
|
return chosen.model_id, chosen.connection_id
|
||||||
|
|
||||||
|
|
||||||
def available_models(db: DBSession, user=None) -> list[Model]:
|
def available_models(db: DBSession, user=None) -> list[Model]:
|
||||||
"""Models this user may start a chat with, pinned first."""
|
"""Models this user may start a chat with, in the administrator's order.
|
||||||
|
|
||||||
|
Pinning does NOT hoist a model up this list: pinned models get their own
|
||||||
|
shortcuts in the sidebar, and a picker whose order silently differs from
|
||||||
|
the one configured in the admin screen is just confusing.
|
||||||
|
"""
|
||||||
from lembas.security import permissions
|
from lembas.security import permissions
|
||||||
|
|
||||||
reachable = permissions.models_visible_to(db, user)
|
reachable = permissions.models_visible_to(db, user)
|
||||||
return sorted(reachable, key=lambda m: (not m.pinned, m.position, m.model_id))
|
return sorted(reachable, key=lambda m: (m.position, m.model_id))
|
||||||
|
|
||||||
|
|
||||||
def fallback_title(text: str) -> str:
|
def fallback_title(text: str) -> str:
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ def _defaults() -> dict[str, Any]:
|
|||||||
# until an administrator approves them. Reserved for the users pass.
|
# until an administrator approves them. Reserved for the users pass.
|
||||||
"require_approval": False,
|
"require_approval": False,
|
||||||
"instance_name": "LLeMbas",
|
"instance_name": "LLeMbas",
|
||||||
|
# Applied to every chat that has no model or chat prompt of its
|
||||||
|
# own. See services.chat.effective_system_prompt.
|
||||||
|
"system_prompt": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
/* Administration screens. */
|
/* Settings and administration screens. */
|
||||||
|
|
||||||
.admin-scroll {
|
/* --- Page scaffolding ------------------------------------------------------ */
|
||||||
|
.admin-scroll,
|
||||||
|
.tabs__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border-strong) transparent;
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page,
|
||||||
.admin-page {
|
.admin-page {
|
||||||
max-width: 46rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--sp-6) var(--sp-5) var(--sp-12);
|
padding: var(--sp-6) var(--sp-5) var(--sp-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-lede {
|
.page-header { margin-bottom: var(--sp-6); }
|
||||||
|
.admin-lede,
|
||||||
|
.page-header__lede {
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
line-height: var(--leading-relaxed);
|
line-height: var(--leading-relaxed);
|
||||||
margin-bottom: var(--sp-6);
|
margin: 0 0 var(--sp-6);
|
||||||
max-width: 42rem;
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-title {
|
.admin-section-title,
|
||||||
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
@@ -29,6 +36,60 @@
|
|||||||
margin: var(--sp-8) 0 var(--sp-4);
|
margin: var(--sp-8) 0 var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Tabs ------------------------------------------------------------------
|
||||||
|
Radio inputs plus sibling selectors: no JavaScript, and the browser keeps
|
||||||
|
the chosen tab across a re-render.
|
||||||
|
*/
|
||||||
|
.tabs { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||||
|
|
||||||
|
.tabs__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
padding: 0 var(--sp-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
flex: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs__tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
height: var(--control-h-lg);
|
||||||
|
padding: 0 var(--sp-4);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.tabs__tab:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.tabs__panel { display: none; }
|
||||||
|
|
||||||
|
/* Each radio activates its own label and its own panel. Written out because
|
||||||
|
CSS has no way to derive one from the other. */
|
||||||
|
#tab-account:checked ~ label[for="tab-account"],
|
||||||
|
#tab-models:checked ~ label[for="tab-models"],
|
||||||
|
#tab-appearance:checked ~ label[for="tab-appearance"],
|
||||||
|
#tab-security:checked ~ label[for="tab-security"] {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.tabs__bar:has(#tab-account:checked) ~ .tabs__body [data-tab="tab-account"],
|
||||||
|
.tabs__bar:has(#tab-models:checked) ~ .tabs__body [data-tab="tab-models"],
|
||||||
|
.tabs__bar:has(#tab-appearance:checked) ~ .tabs__body [data-tab="tab-appearance"],
|
||||||
|
.tabs__bar:has(#tab-security:checked) ~ .tabs__body [data-tab="tab-security"] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.tabs__tab:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
|
||||||
|
/* --- Cards ----------------------------------------------------------------- */
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -36,27 +97,21 @@
|
|||||||
padding: var(--sp-5);
|
padding: var(--sp-5);
|
||||||
margin-bottom: var(--sp-4);
|
margin-bottom: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
.card:last-child { margin-bottom: 0; }
|
||||||
.card__title {
|
.card__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
font-size: var(--text-md);
|
font-size: var(--text-md);
|
||||||
margin-bottom: var(--sp-4);
|
margin-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
.card__lede {
|
||||||
.form-grid { display: block; }
|
color: var(--ink-muted);
|
||||||
.form-grid .field:last-of-type { margin-bottom: 0; }
|
font-size: var(--text-sm);
|
||||||
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
|
|
||||||
|
|
||||||
.connection__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
margin-bottom: var(--sp-4);
|
margin-bottom: var(--sp-4);
|
||||||
flex-wrap: wrap;
|
line-height: var(--leading-relaxed);
|
||||||
}
|
}
|
||||||
.connection__footer {
|
.card__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -66,8 +121,36 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reachable status at a glance: green working, red errored, grey disabled. */
|
/* Legacy aliases so existing admin templates keep their spacing. */
|
||||||
|
.form-grid { display: block; }
|
||||||
|
.connection__head { display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; }
|
||||||
|
.connection__footer { display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: var(--sp-3); margin-top: var(--sp-5); padding-top: var(--sp-4);
|
||||||
|
border-top: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* --- Definition lists ------------------------------------------------------ */
|
||||||
|
.detail-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(6rem, auto) 1fr;
|
||||||
|
gap: var(--sp-2) var(--sp-4);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.detail-list dt { color: var(--ink-muted); font-weight: 500; }
|
||||||
|
.detail-list dd { margin: 0; }
|
||||||
|
|
||||||
|
/* --- Status ---------------------------------------------------------------- */
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 0.55rem;
|
width: 0.55rem;
|
||||||
height: 0.55rem;
|
height: 0.55rem;
|
||||||
@@ -79,44 +162,25 @@
|
|||||||
.status-dot.is-bad { background: var(--danger); }
|
.status-dot.is-bad { background: var(--danger); }
|
||||||
.status-dot.is-off { background: var(--ink-faint); }
|
.status-dot.is-off { background: var(--ink-faint); }
|
||||||
|
|
||||||
.model-list { list-style: none; margin: 0; padding: 0; }
|
/* --- Model list ------------------------------------------------------------ */
|
||||||
.model-list__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-2) 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.model-list__item:last-child { border-bottom: 0; }
|
|
||||||
.model-list__id {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.is-disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
|
|
||||||
|
|
||||||
/* --- Model list ----------------------------------------------------------- */
|
|
||||||
.bulk-bar {
|
.bulk-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding: var(--sp-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
margin-bottom: var(--sp-4);
|
margin-bottom: var(--sp-4);
|
||||||
}
|
}
|
||||||
.bulk-bar .model-rows { flex-basis: 100%; margin-top: var(--sp-3); }
|
.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); margin-right: var(--sp-1); }
|
||||||
|
|
||||||
.model-rows {
|
.model-rows {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.model-row {
|
.model-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -124,31 +188,43 @@
|
|||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--surface);
|
|
||||||
}
|
}
|
||||||
.model-row:last-child { border-bottom: 0; }
|
.model-row:last-child { border-bottom: 0; }
|
||||||
.model-row.is-off { opacity: 0.55; }
|
.model-row.is-off { opacity: 0.5; }
|
||||||
.model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
|
.model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
|
||||||
.model-row__avatar { flex: none; }
|
.model-row__avatar { flex: none; width: 2rem; height: 2rem; }
|
||||||
.model-row__main { flex: 1; min-width: 0; }
|
.model-row__main { flex: 1; min-width: 0; }
|
||||||
.model-row__title {
|
.model-row__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
}
|
}
|
||||||
.model-row__id {
|
.model-row__id {
|
||||||
display: block;
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-faint);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
.model-row__actions { display: flex; gap: var(--sp-1); flex: none; align-items: center; }
|
.model-row__actions { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
|
||||||
|
|
||||||
/* --- Permission and checkbox grids ---------------------------------------- */
|
.model-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.model-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.model-list__item:first-child { padding-top: 0; }
|
||||||
|
.model-list__item:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
|
||||||
|
/* --- Permission grids ------------------------------------------------------ */
|
||||||
.checkbox-row {
|
.checkbox-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -159,26 +235,52 @@
|
|||||||
|
|
||||||
.perm-row {
|
.perm-row {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: var(--sp-2) 0;
|
padding: var(--sp-3) 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.perm-row:last-child { border-bottom: 0; }
|
.perm-row:last-child { border-bottom: 0; }
|
||||||
.perm-row input { margin-top: 0.2rem; }
|
.perm-row input { margin-top: 0.15rem; }
|
||||||
.perm-row__desc {
|
.perm-row__desc {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input--file {
|
.perm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
|
||||||
padding: 0.35rem;
|
.perm-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.perm-list__state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.perm-list__state.is-on { background: var(--success-soft); color: var(--success); }
|
||||||
|
|
||||||
|
/* --- Misc ------------------------------------------------------------------ */
|
||||||
|
.input--file {
|
||||||
|
height: auto;
|
||||||
|
padding: 0.35rem;
|
||||||
|
font-size: var(--text-xs);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field__hint code {
|
.nav-item.is-disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
|
||||||
|
|
||||||
|
.field__hint code,
|
||||||
|
.card__lede code {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
padding: 0.05em 0.3em;
|
padding: 0.05em 0.3em;
|
||||||
|
|||||||
@@ -84,29 +84,37 @@ button, input, textarea, select {
|
|||||||
/* The leaf is a filled silhouette, not a stroked pictogram. */
|
/* The leaf is a filled silhouette, not a stroked pictogram. */
|
||||||
.icon--leaf { fill: currentColor; stroke: none; }
|
.icon--leaf { fill: currentColor; stroke: none; }
|
||||||
|
|
||||||
/* --- Buttons -------------------------------------------------------------- */
|
/* --- Buttons ---------------------------------------------------------------
|
||||||
|
Every variant is the same height and vertically centres its contents, so a
|
||||||
|
row of mixed buttons lines up without per-instance nudging. Icon buttons are
|
||||||
|
square at that height rather than a different shape.
|
||||||
|
*/
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
padding: 0.5rem 0.9rem;
|
height: var(--control-h);
|
||||||
|
padding: 0 var(--control-px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
transition: background var(--transition-fast), border-color var(--transition-fast),
|
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||||
color var(--transition-fast);
|
color var(--transition-fast);
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.btn:hover:not(:disabled) {
|
.btn:hover:not(:disabled) {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
.btn--primary {
|
.btn--primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -116,37 +124,58 @@ button, input, textarea, select {
|
|||||||
.btn--primary:hover:not(:disabled) {
|
.btn--primary:hover:not(:disabled) {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
border-color: var(--accent-hover);
|
border-color: var(--accent-hover);
|
||||||
|
color: var(--accent-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--danger { color: var(--danger); border-color: var(--border); }
|
.btn--danger { color: var(--danger); }
|
||||||
.btn--danger:hover:not(:disabled) {
|
.btn--danger:hover:not(:disabled) {
|
||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--ghost { background: transparent; border-color: transparent; }
|
.btn--ghost { background: transparent; border-color: transparent; }
|
||||||
.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); border-color: transparent; }
|
.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); border-color: transparent; }
|
||||||
|
|
||||||
|
/* Square, and the same height as everything beside it. */
|
||||||
.btn--icon {
|
.btn--icon {
|
||||||
padding: 0.4rem;
|
width: var(--control-h);
|
||||||
border-radius: var(--radius);
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
.btn--icon:hover:not(:disabled) { background: var(--surface-hover); color: var(--ink); }
|
.btn--icon:hover:not(:disabled) { background: var(--surface-hover); color: var(--ink); }
|
||||||
|
.btn--icon.btn--primary {
|
||||||
|
color: var(--accent-ink);
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--sm { height: var(--control-h-sm); padding: 0 var(--control-px-sm); font-size: var(--text-xs); }
|
||||||
|
.btn--sm.btn--icon { width: var(--control-h-sm); padding: 0; }
|
||||||
|
.btn--lg { height: var(--control-h-lg); padding: 0 var(--sp-5); font-size: var(--text-base); }
|
||||||
|
|
||||||
.btn--block { width: 100%; }
|
.btn--block { width: 100%; }
|
||||||
.btn--sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
|
.btn--grow { flex: 1; }
|
||||||
|
|
||||||
/* --- Forms ---------------------------------------------------------------- */
|
/* Rows of buttons: gap and alignment in one place, not per instance. */
|
||||||
|
.btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.btn-row--end { justify-content: flex-end; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* --- Forms -----------------------------------------------------------------
|
||||||
|
Inputs share the button height, so a control row is flush by construction.
|
||||||
|
*/
|
||||||
.field { margin-bottom: var(--sp-4); }
|
.field { margin-bottom: var(--sp-4); }
|
||||||
|
.field:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
.field__label {
|
.field__label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--sp-2);
|
margin-bottom: var(--sp-2);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--ink-muted);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.field__hint {
|
.field__hint {
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
@@ -156,17 +185,31 @@ button, input, textarea, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input,
|
.input,
|
||||||
.textarea,
|
|
||||||
.select {
|
.select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.55rem 0.7rem;
|
height: var(--control-h);
|
||||||
|
padding: 0 var(--control-px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg-sunken);
|
background: var(--bg-sunken);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-sm);
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--sp-2) var(--control-px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5rem;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
.input:focus,
|
.input:focus,
|
||||||
.textarea:focus,
|
.textarea:focus,
|
||||||
.select:focus {
|
.select:focus {
|
||||||
@@ -175,8 +218,29 @@ button, input, textarea, select {
|
|||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
}
|
}
|
||||||
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
|
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
|
||||||
.textarea { resize: vertical; min-height: 5rem; line-height: var(--leading-normal); }
|
.input--mono, .textarea--mono { font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||||
.input--mono { font-family: var(--font-mono); font-size: var(--text-sm); }
|
|
||||||
|
.select {
|
||||||
|
appearance: none;
|
||||||
|
padding-right: var(--sp-8);
|
||||||
|
/* Chevron drawn in CSS, so no icon font and no extra element. */
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, transparent 50%, currentColor 50%),
|
||||||
|
linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||||
|
background-position: right 1.1rem center, right 0.85rem center;
|
||||||
|
background-size: 0.3rem 0.3rem, 0.3rem 0.3rem;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* Sits inside a composed control that draws its own frame (the model picker). */
|
||||||
|
.select--bare {
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
width: auto;
|
||||||
|
max-width: 14rem;
|
||||||
|
padding-left: var(--sp-1);
|
||||||
|
}
|
||||||
|
.select--bare:focus { box-shadow: none; }
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -184,8 +248,20 @@ button, input, textarea, select {
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
line-height: var(--leading-normal);
|
||||||
}
|
}
|
||||||
.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; }
|
.checkbox input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-column form layout, one definition. */
|
||||||
|
.grid { display: grid; gap: var(--sp-4); }
|
||||||
|
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
|
||||||
|
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); }
|
||||||
|
|
||||||
/* --- Alerts --------------------------------------------------------------- */
|
/* --- Alerts --------------------------------------------------------------- */
|
||||||
.alert {
|
.alert {
|
||||||
@@ -220,12 +296,8 @@ button, input, textarea, select {
|
|||||||
.badge--success { background: var(--success-soft); color: var(--success); }
|
.badge--success { background: var(--success-soft); color: var(--success); }
|
||||||
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
|
||||||
/* --- Application shell ---------------------------------------------------- */
|
/* --- Application shell ----------------------------------------------------- */
|
||||||
.shell {
|
.shell { display: flex; height: 100dvh; overflow: hidden; }
|
||||||
display: flex;
|
|
||||||
height: 100dvh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
@@ -234,14 +306,13 @@ button, input, textarea, select {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-sunken);
|
background: var(--bg-sunken);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
transition: margin-left var(--transition);
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.sidebar[hidden] { display: none; }
|
.sidebar[hidden] { display: none; }
|
||||||
|
|
||||||
.sidebar__header {
|
.sidebar__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
padding: 0 var(--sp-3);
|
padding: 0 var(--sp-3);
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -259,13 +330,20 @@ button, input, textarea, select {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.sidebar__brand:hover { color: var(--ink); }
|
.sidebar__brand:hover { color: var(--ink); }
|
||||||
.sidebar__brand .brand-mark { width: 1.6rem; height: 1.6rem; flex: none; }
|
.sidebar__brand .brand-mark { width: 1.65rem; height: 1.65rem; flex: none; }
|
||||||
.sidebar__brand .brand-llm { color: var(--gold); }
|
.sidebar__brand .brand-llm { color: var(--gold); }
|
||||||
|
|
||||||
.sidebar__actions { padding: 0 var(--sp-3) var(--sp-3); display: flex; gap: var(--sp-2); }
|
.sidebar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 0 var(--sp-3) var(--sp-3);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar__scroll {
|
.sidebar__scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 var(--sp-2) var(--sp-3);
|
padding: 0 var(--sp-2) var(--sp-3);
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -276,13 +354,18 @@ button, input, textarea, select {
|
|||||||
flex: none;
|
flex: none;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: var(--sp-2);
|
padding: var(--sp-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
.sidebar__tools { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,10 +389,52 @@ button, input, textarea, select {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.topbar__spacer { flex: 1; }
|
.topbar__actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; }
|
||||||
|
|
||||||
/* --- Sidebar navigation --------------------------------------------------- */
|
/* The model picker: an avatar and a select sharing one frame, so it reads as a
|
||||||
|
single control rather than two things that happen to be adjacent. */
|
||||||
|
.model-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
height: var(--control-h);
|
||||||
|
padding: 0 var(--sp-1) 0 var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.model-select:hover { border-color: var(--border-strong); }
|
||||||
|
.model-select:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.model-select__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
/* Collapsible settings panel, shared by chat settings and anything like it. */
|
||||||
|
.panel {
|
||||||
|
flex: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.panel__inner {
|
||||||
|
max-width: var(--thread-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--sp-5);
|
||||||
|
}
|
||||||
|
.panel__note {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sidebar navigation ---------------------------------------------------- */
|
||||||
.nav-group { margin-bottom: var(--sp-4); }
|
.nav-group { margin-bottom: var(--sp-4); }
|
||||||
|
.nav-group:last-child { margin-bottom: 0; }
|
||||||
.nav-group__label {
|
.nav-group__label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -326,7 +451,8 @@ button, input, textarea, select {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
padding: 0.4rem var(--sp-2);
|
min-height: var(--control-h);
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -335,7 +461,11 @@ button, input, textarea, select {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.nav-item:hover { background: var(--surface-hover); color: var(--ink); }
|
.nav-item:hover { background: var(--surface-hover); color: var(--ink); }
|
||||||
.nav-item.is-active { background: var(--surface-active); color: var(--ink); font-weight: 500; }
|
.nav-item.is-active {
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.nav-item__label {
|
.nav-item__label {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -343,11 +473,16 @@ button, input, textarea, select {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
/* Row actions stay hidden until the row is hovered or focused within, so the
|
.nav-item__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; }
|
||||||
list reads calmly, but they remain keyboard reachable. */
|
.nav-item--model .nav-item__label { font-weight: 500; }
|
||||||
|
|
||||||
|
/* Row actions stay hidden until hover or focus, so the list reads calmly while
|
||||||
|
remaining keyboard reachable. */
|
||||||
.nav-item__actions {
|
.nav-item__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
|
flex: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
}
|
}
|
||||||
@@ -355,7 +490,8 @@ button, input, textarea, select {
|
|||||||
.nav-item:focus-within .nav-item__actions { opacity: 1; }
|
.nav-item:focus-within .nav-item__actions { opacity: 1; }
|
||||||
|
|
||||||
.nav-empty {
|
.nav-empty {
|
||||||
padding: var(--sp-3) var(--sp-2);
|
padding: var(--sp-2);
|
||||||
|
margin: 0;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.msg__mark { width: 2rem; height: 2rem; }
|
.msg__mark { width: 2rem; height: 2rem; }
|
||||||
|
.msg__avatar { width: 2rem; height: 2rem; border-radius: var(--radius); }
|
||||||
.msg__initial {
|
.msg__initial {
|
||||||
width: 2rem; height: 2rem;
|
width: 2rem; height: 2rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -310,11 +311,10 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
.composer__inner { max-width: var(--thread-max-width); margin: 0 auto; }
|
||||||
.composer__form {
|
.composer__form {
|
||||||
max-width: var(--thread-max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-1);
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding: var(--sp-2);
|
padding: var(--sp-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -322,37 +322,33 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
/* Attach and send are the same size and sit on the same baseline as the last
|
||||||
|
line of the textarea, so the control row reads as one object. */
|
||||||
|
.composer__btn { flex: none; align-self: flex-end; border-radius: var(--radius-full); }
|
||||||
.composer__form:focus-within {
|
.composer__form:focus-within {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
}
|
}
|
||||||
.composer__input {
|
.composer__input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
padding: var(--sp-2);
|
padding: 0.5rem var(--sp-2);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
max-height: 20rem;
|
max-height: 20rem;
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.composer__input:focus { outline: none; }
|
.composer__input:focus { outline: none; }
|
||||||
.composer__send { border-radius: var(--radius-full); padding: 0.55rem 0.7rem; }
|
|
||||||
.composer__hint {
|
.composer__hint {
|
||||||
max-width: var(--thread-max-width);
|
margin: var(--sp-2) 0 0;
|
||||||
margin: var(--sp-2) auto 0;
|
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select--compact {
|
|
||||||
width: auto;
|
|
||||||
max-width: 16rem;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Folders -------------------------------------------------------------- */
|
/* --- Folders -------------------------------------------------------------- */
|
||||||
.folder__row { padding-right: var(--sp-1); }
|
.folder__row { padding-right: var(--sp-1); }
|
||||||
.folder__toggle {
|
.folder__toggle {
|
||||||
@@ -386,38 +382,11 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Chat settings panel -------------------------------------------------- */
|
|
||||||
.chat-settings {
|
|
||||||
flex: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-sunken);
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.chat-settings__inner {
|
|
||||||
max-width: var(--thread-max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
}
|
|
||||||
.chat-settings__note {
|
|
||||||
color: var(--ink-muted);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-style: italic;
|
|
||||||
margin-bottom: var(--sp-4);
|
|
||||||
}
|
|
||||||
.chat-settings__params {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
|
||||||
gap: var(--sp-3);
|
|
||||||
}
|
|
||||||
.chat-settings__params .field { margin-bottom: 0; }
|
|
||||||
|
|
||||||
/* --- Attachment chips (composer) ------------------------------------------ */
|
/* --- Attachment chips (composer) ------------------------------------------ */
|
||||||
.composer { position: relative; }
|
.composer { position: relative; }
|
||||||
|
|
||||||
.composer__attachments {
|
.composer__attachments {
|
||||||
max-width: var(--thread-max-width);
|
margin: 0 0 var(--sp-2);
|
||||||
margin: 0 auto var(--sp-2);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
@@ -451,8 +420,6 @@
|
|||||||
.chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
|
.chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
|
||||||
.chip__warning { font-size: var(--text-xs); color: var(--danger); }
|
.chip__warning { font-size: var(--text-xs); color: var(--danger); }
|
||||||
|
|
||||||
.composer__attach { flex: none; align-self: flex-end; }
|
|
||||||
|
|
||||||
/* --- Drag and drop -------------------------------------------------------- */
|
/* --- Drag and drop -------------------------------------------------------- */
|
||||||
.dropzone-overlay {
|
.dropzone-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -534,7 +501,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- Theme toggle --------------------------------------------------------- */
|
/* --- Theme toggle --------------------------------------------------------- */
|
||||||
/* Only the icon for the theme you would switch TO is shown. */
|
/* Only the icon for the theme you would switch TO is shown. Both live in the
|
||||||
|
same button, so the wrapper must not add a line box of its own. */
|
||||||
|
.theme-icon { display: flex; }
|
||||||
:root[data-theme="moria"] .theme-icon--dark { display: none; }
|
:root[data-theme="moria"] .theme-icon--dark { display: none; }
|
||||||
:root[data-theme="shire"] .theme-icon--light { display: none; }
|
:root[data-theme="shire"] .theme-icon--light { display: none; }
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,22 @@
|
|||||||
--radius-xl: 18px;
|
--radius-xl: 18px;
|
||||||
--radius-full: 999px;
|
--radius-full: 999px;
|
||||||
|
|
||||||
|
/*
|
||||||
|
--- Controls ----------------------------------------------------------
|
||||||
|
Every button, input and select resolves its height from these. That is the
|
||||||
|
whole reason things line up: a row of mixed controls has one height, not
|
||||||
|
whatever each element's padding and font happened to add up to.
|
||||||
|
*/
|
||||||
|
--control-h: 2.25rem;
|
||||||
|
--control-h-sm: 1.75rem;
|
||||||
|
--control-h-lg: 2.75rem;
|
||||||
|
--control-px: 0.75rem;
|
||||||
|
--control-px-sm: 0.5rem;
|
||||||
|
|
||||||
/* --- Layout ----------------------------------------------------------- */
|
/* --- Layout ----------------------------------------------------------- */
|
||||||
--sidebar-width: 17rem;
|
--sidebar-width: 17.5rem;
|
||||||
--thread-max-width: 48rem;
|
--thread-max-width: 48rem;
|
||||||
--header-height: 3.25rem;
|
--header-height: 3.5rem;
|
||||||
|
|
||||||
--transition-fast: 120ms ease;
|
--transition-fast: 120ms ease;
|
||||||
--transition: 200ms ease;
|
--transition: 200ms ease;
|
||||||
|
|||||||
@@ -211,6 +211,20 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
var source = document.getElementById(copy.dataset.copy);
|
var source = document.getElementById(copy.dataset.copy);
|
||||||
if (source) copyText(source.textContent.trim(), copy);
|
if (source) copyText(source.textContent.trim(), copy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show/hide a panel by selector, so templates do not each carry their own
|
||||||
|
inline toggle script. */
|
||||||
|
var toggle = event.target.closest("[data-toggle]");
|
||||||
|
if (toggle) {
|
||||||
|
event.preventDefault();
|
||||||
|
var panel = document.querySelector(toggle.dataset.toggle);
|
||||||
|
if (!panel) return;
|
||||||
|
var nowOpen = panel.hasAttribute("hidden");
|
||||||
|
panel.toggleAttribute("hidden");
|
||||||
|
toggle.setAttribute("aria-expanded", nowOpen ? "true" : "false");
|
||||||
|
toggle.classList.toggle("is-active", nowOpen);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
#}
|
#}
|
||||||
<section class="card connection" id="connection-{{ connection.id }}">
|
<section class="card connection" id="connection-{{ connection.id }}">
|
||||||
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
|
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
|
||||||
<div class="connection__head">
|
<div class="card__header">
|
||||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||||
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
|
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
|
||||||
else 'is-bad' if connection.last_error else 'is-off' }}"
|
else 'is-bad' if connection.last_error else 'is-off' }}"
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" style="gap: var(--sp-2)">
|
<div class="btn-row">
|
||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
hx-post="/admin/connections/{{ connection.id }}/test"
|
hx-post="/admin/connections/{{ connection.id }}/test"
|
||||||
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
|
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="connection__footer">
|
<div class="card__footer">
|
||||||
<span class="text-xs faint">
|
<span class="text-xs faint">
|
||||||
{% if connection.last_checked_at %}
|
{% if connection.last_checked_at %}
|
||||||
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="card__title">Add a connection</h2>
|
<h2 class="card__title">Add a connection</h2>
|
||||||
<form method="post" action="/admin/connections" class="form-grid">
|
<form method="post" action="/admin/connections" >
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="new-name">Name</label>
|
<label class="field__label" for="new-name">Name</label>
|
||||||
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
|
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field field--actions">
|
<div class="btn-row" style="margin-top: var(--sp-5)">
|
||||||
<button class="btn btn--primary" type="submit">
|
<button class="btn btn--primary" type="submit">
|
||||||
{{ icon("plus", "icon--sm") }} Add and load models
|
{{ icon("plus", "icon--sm") }} Add and load models
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,6 +26,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">Default system prompt</h2>
|
||||||
|
<p class="card__lede">
|
||||||
|
Applied to every chat that does not have a prompt of its own. A model's
|
||||||
|
prompt overrides this, and a chat's prompt overrides both — most specific
|
||||||
|
wins outright rather than the three being stacked together.
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label visually-hidden" for="system-prompt">System prompt</label>
|
||||||
|
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
|
||||||
|
placeholder="You are a helpful assistant.">{{ values.system_prompt }}</textarea>
|
||||||
|
<p class="field__hint">Leave empty to send no system prompt at all.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="card__title">
|
<h2 class="card__title">
|
||||||
Registration
|
Registration
|
||||||
@@ -69,6 +84,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button class="btn btn--primary" type="submit">Save settings</button>
|
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button class="btn btn--primary" type="submit">Save baseline</button>
|
<div class="btn-row"><button class="btn btn--primary" type="submit">Save baseline</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<form method="post" action="/admin/groups" class="row" style="gap: var(--sp-2)">
|
<form method="post" action="/admin/groups" class="btn-row">
|
||||||
<input class="input" name="name" placeholder="New group name" required
|
<input class="input" name="name" placeholder="New group name" required
|
||||||
aria-label="New group name">
|
aria-label="New group name">
|
||||||
<button class="btn btn--primary" type="submit">
|
<button class="btn btn--primary" type="submit">
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
{% for group in groups %}
|
{% for group in groups %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<form method="post" action="/admin/groups/{{ group.id }}">
|
<form method="post" action="/admin/groups/{{ group.id }}">
|
||||||
<div class="row row--between" style="margin-bottom: var(--sp-4)">
|
<div class="card__header">
|
||||||
<strong>{{ group.name }}</strong>
|
<strong>{{ group.name }}</strong>
|
||||||
<span class="text-xs faint">
|
<span class="text-xs faint">
|
||||||
{{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }},
|
{{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }},
|
||||||
@@ -150,10 +150,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
|
<div class="btn-row">
|
||||||
|
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="connection__footer">
|
<div class="card__footer">
|
||||||
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
|
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
|
||||||
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
||||||
onsubmit="return confirm('Delete the group “{{ group.name }}”?')">
|
onsubmit="return confirm('Delete the group “{{ group.name }}”?')">
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<p class="admin-lede">
|
<p class="admin-lede">
|
||||||
Every model discovered across your connections. The order here is the order
|
Every model discovered across your connections. The order here is the order
|
||||||
users see. Pinned models are offered first, and the default is what a new chat
|
users see in the picker. <strong>Pinning</strong> does not reorder anything —
|
||||||
starts with.
|
it puts a shortcut in the chat sidebar. The <strong>default</strong> is what a
|
||||||
|
new chat starts with.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if saved %}
|
{% if saved %}
|
||||||
@@ -26,12 +27,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
<form method="post" action="/admin/models/bulk" class="bulk-bar">
|
<form method="post" action="/admin/models/bulk">
|
||||||
<span class="text-sm muted">With selected:</span>
|
<div class="bulk-bar">
|
||||||
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
|
<span class="bulk-bar__label">With selected:</span>
|
||||||
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
|
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
|
||||||
<button class="btn btn--sm" name="action" value="public" type="submit">Make public</button>
|
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
|
||||||
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
|
<button class="btn btn--sm" name="action" value="public" type="submit">Make public</button>
|
||||||
|
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="model-rows">
|
<div class="model-rows">
|
||||||
{% for model in models %}
|
{% for model in models %}
|
||||||
@@ -82,7 +85,7 @@
|
|||||||
|
|
||||||
{% for model in models %}
|
{% for model in models %}
|
||||||
<section class="card" id="model-{{ model.id }}">
|
<section class="card" id="model-{{ model.id }}">
|
||||||
<div class="row row--between" style="margin-bottom: var(--sp-4)">
|
<div class="card__header">
|
||||||
<div class="row" style="gap: var(--sp-3); min-width: 0">
|
<div class="row" style="gap: var(--sp-3); min-width: 0">
|
||||||
{{ model_avatar(model, cls="model-row__avatar") }}
|
{{ model_avatar(model, cls="model-row__avatar") }}
|
||||||
<div style="min-width: 0">
|
<div style="min-width: 0">
|
||||||
@@ -111,6 +114,18 @@
|
|||||||
placeholder="What is this model good at?">{{ model.description }}</textarea>
|
placeholder="What is this model good at?">{{ model.description }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="sp-{{ model.id }}">System prompt</label>
|
||||||
|
<textarea class="textarea" id="sp-{{ model.id }}" name="system_prompt" rows="3"
|
||||||
|
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
|
||||||
|
>{{ model.system_prompt }}</textarea>
|
||||||
|
<p class="field__hint">
|
||||||
|
Used by chats on this model that have no prompt of their own.
|
||||||
|
{% if instance_prompt %}Leave empty to fall back to the instance prompt.
|
||||||
|
{% else %}Leave empty to send no system prompt.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field__label">Capabilities</span>
|
<span class="field__label">Capabilities</span>
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
@@ -138,7 +153,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
|
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
|
||||||
<span>Pinned — offered first in the picker</span>
|
<span>Pinned — shortcut in the chat sidebar</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,12 +184,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn--primary" type="submit">Save {{ model.label }}</button>
|
<div class="btn-row">
|
||||||
|
<button class="btn btn--primary" type="submit">Save {{ model.label }}</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="connection__footer">
|
<div class="card__footer">
|
||||||
<form method="post" action="/admin/models/{{ model.id }}/image"
|
<form method="post" action="/admin/models/{{ model.id }}/image"
|
||||||
enctype="multipart/form-data" class="row" style="gap: var(--sp-2)">
|
enctype="multipart/form-data" class="btn-row">
|
||||||
<input class="input input--file" type="file" name="image"
|
<input class="input input--file" type="file" name="image"
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif" required
|
accept="image/png,image/jpeg,image/webp,image/gif" required
|
||||||
aria-label="Model image">
|
aria-label="Model image">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="get" action="/admin/users" class="row" style="margin-bottom: var(--sp-5)">
|
<form method="get" action="/admin/users" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||||
<input class="input" type="search" name="q" value="{{ q }}"
|
<input class="input" type="search" name="q" value="{{ q }}"
|
||||||
placeholder="Search by name or email" aria-label="Search users">
|
placeholder="Search by name or email" aria-label="Search users">
|
||||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button>
|
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button>
|
||||||
@@ -50,7 +50,9 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
<div class="btn-row">
|
||||||
|
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@
|
|||||||
{% for account in users %}
|
{% for account in users %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<form method="post" action="/admin/users/{{ account.id }}">
|
<form method="post" action="/admin/users/{{ account.id }}">
|
||||||
<div class="row row--between" style="margin-bottom: var(--sp-4); flex-wrap: wrap">
|
<div class="card__header">
|
||||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||||
<span class="status-dot {{ 'is-ok' if account.active else 'is-off' }}"></span>
|
<span class="status-dot {{ 'is-ok' if account.active else 'is-off' }}"></span>
|
||||||
<strong class="truncate">{{ account.name }}</strong>
|
<strong class="truncate">{{ account.name }}</strong>
|
||||||
@@ -135,12 +137,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<button class="btn btn--primary" type="submit">Save</button>
|
<div class="btn-row"><button class="btn btn--primary" type="submit">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="connection__footer">
|
<div class="card__footer">
|
||||||
<form method="post" action="/admin/users/{{ account.id }}/password" class="row"
|
<form method="post" action="/admin/users/{{ account.id }}/password" class="btn-row">
|
||||||
style="gap: var(--sp-2)">
|
|
||||||
<input class="input" type="password" name="password" minlength="8"
|
<input class="input" type="password" name="password" minlength="8"
|
||||||
placeholder="Set a new password" autocomplete="new-password" required
|
placeholder="Set a new password" autocomplete="new-password" required
|
||||||
aria-label="New password for {{ account.email }}">
|
aria-label="New password for {{ account.email }}">
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{#
|
||||||
|
The composer, used both inside an existing chat and on /chat where no chat
|
||||||
|
row exists yet.
|
||||||
|
|
||||||
|
The only difference is where it posts. With a chat, the turn is appended to
|
||||||
|
the thread in place; without one, /api/chats/start creates the chat and
|
||||||
|
redirects, and the reply streams on arrival because the page renders the
|
||||||
|
unfinished assistant message with its sse-connect. That is what stops an
|
||||||
|
opened-and-abandoned chat ever being written to the database.
|
||||||
|
#}
|
||||||
|
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
|
||||||
|
{% if can.get("files.upload") %}
|
||||||
|
<form id="upload-form" hx-post="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
|
||||||
|
hx-target="#attachments" hx-swap="beforeend"
|
||||||
|
hx-encoding="multipart/form-data" hx-on::after-request="this.reset()">
|
||||||
|
<input class="visually-hidden" type="file" name="file" id="file-input" multiple
|
||||||
|
accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
|
||||||
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="composer__inner">
|
||||||
|
<div class="composer__attachments" id="attachments"></div>
|
||||||
|
|
||||||
|
<form class="composer__form"
|
||||||
|
{% if chat %}
|
||||||
|
hx-post="/api/chats/{{ chat.id }}/messages"
|
||||||
|
hx-target="#thread" hx-swap="beforeend"
|
||||||
|
hx-on::after-request="if (event.detail.successful) {
|
||||||
|
this.reset();
|
||||||
|
document.getElementById('attachments').replaceChildren();
|
||||||
|
window.lembas.autosize(this.querySelector('textarea'));
|
||||||
|
window.lembas.scrollThread(true);
|
||||||
|
}"
|
||||||
|
{% else %}
|
||||||
|
hx-post="/api/chats/start" hx-swap="none"
|
||||||
|
{% endif %}>
|
||||||
|
|
||||||
|
{# The chips live outside this form, so their hidden inputs are pulled in
|
||||||
|
explicitly at submit time. #}
|
||||||
|
<div hx-include="#attachments" hidden></div>
|
||||||
|
{% if not chat and current_model %}
|
||||||
|
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can.get("files.upload") %}
|
||||||
|
<button class="btn btn--icon composer__btn" type="button"
|
||||||
|
aria-label="Attach a file" title="Attach a file"
|
||||||
|
onclick="document.getElementById('file-input').click()">
|
||||||
|
{{ icon("attach") }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<textarea class="composer__input" name="content" rows="1"
|
||||||
|
data-autosize data-max-height="320" data-composer-input
|
||||||
|
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
||||||
|
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||||
|
|
||||||
|
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
||||||
|
aria-label="Send">
|
||||||
|
{{ icon("send") }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="composer__hint">
|
||||||
|
Enter to send, Shift+Enter for a new line.
|
||||||
|
{% if can.get("files.upload") %}
|
||||||
|
Drag files in, or paste an image.
|
||||||
|
{% if current_model and not current_model.capabilities_json.get("vision") %}
|
||||||
|
<strong>{{ current_model.label }} has no vision</strong>, so images
|
||||||
|
will not be sent — documents still will.
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can.get("files.upload") %}
|
||||||
|
<div class="dropzone-overlay" aria-hidden="true">
|
||||||
|
{{ icon("attach", "icon--lg") }}
|
||||||
|
<span>Drop to attach</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{% from "_macros.html" import icon, mark %}
|
{% from "_macros.html" import icon, mark, model_avatar %}
|
||||||
|
{% set speaking_model = (models_by_id | default({})).get(message.model_id) %}
|
||||||
{#
|
{#
|
||||||
One message bubble, in either of two states.
|
One message bubble, in either of two states.
|
||||||
|
|
||||||
@@ -22,7 +23,14 @@
|
|||||||
|
|
||||||
<div class="msg__gutter" aria-hidden="true">
|
<div class="msg__gutter" aria-hidden="true">
|
||||||
{% if message.role == "assistant" %}
|
{% if message.role == "assistant" %}
|
||||||
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
|
{# The model that actually wrote this turn, which is not necessarily the
|
||||||
|
one the chat is set to now. Falls back to the LLeMbas mark when the
|
||||||
|
model has been removed or has no image of its own. #}
|
||||||
|
{% if speaking_model %}
|
||||||
|
{{ model_avatar(speaking_model, cls="msg__avatar") }}
|
||||||
|
{% else %}
|
||||||
|
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
|
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -31,9 +39,14 @@
|
|||||||
<div class="msg__main">
|
<div class="msg__main">
|
||||||
<header class="msg__meta">
|
<header class="msg__meta">
|
||||||
<span class="msg__author">
|
<span class="msg__author">
|
||||||
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
|
{% if message.role == "assistant" %}
|
||||||
|
{{ speaking_model.label if speaking_model else "LLeMbas" }}
|
||||||
|
{% else %}
|
||||||
|
{{ user.name or "You" }}
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% if message.model_id %}
|
{% if message.model_id and (not speaking_model or speaking_model.display_name) %}
|
||||||
|
{# Only worth showing when it adds something the author line does not. #}
|
||||||
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
|
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_macros.html" import icon, mark %}
|
{% from "_macros.html" import icon, mark, model_avatar %}
|
||||||
|
|
||||||
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
|
{% block title %}{{ chat.title if chat else "New chat" }} - LLeMbas{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||||
@@ -20,84 +20,94 @@
|
|||||||
{{ icon("sidebar") }}
|
{{ icon("sidebar") }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if chat %}
|
<h1 class="topbar__title">
|
||||||
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
|
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
{% if models and can.get("chat.model_select") %}
|
<div class="topbar__actions">
|
||||||
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
{% if models %}
|
||||||
hx-trigger="change from:find select">
|
{% if can.get("chat.model_select") or not chat %}
|
||||||
<select class="select select--compact" name="model_id" aria-label="Model">
|
{# Models are listed in the administrator's order. Pinning is a
|
||||||
{% if pinned_models %}
|
sidebar shortcut and deliberately does not reorder this. #}
|
||||||
<optgroup label="Pinned">
|
<label class="model-select">
|
||||||
{% for model in pinned_models %}
|
{% if current_model %}{{ model_avatar(current_model, cls="model-select__avatar") }}{% endif %}
|
||||||
<option value="{{ model.model_id }}"
|
<select class="select select--bare" name="model_id" id="model-select"
|
||||||
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
|
aria-label="Model"
|
||||||
{% endfor %}
|
{% if chat %}hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||||
</optgroup>
|
hx-trigger="change"{% else %}onchange="
|
||||||
<optgroup label="Other models">
|
const u = new URL(window.location);
|
||||||
|
u.searchParams.set('model', this.value);
|
||||||
|
window.location = u;
|
||||||
|
"{% endif %}>
|
||||||
|
{% for model in models %}
|
||||||
|
<option value="{{ model.model_id }}"
|
||||||
|
{{ 'selected' if current_model and model.model_id == current_model.model_id }}>
|
||||||
|
{{ model.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% elif current_model %}
|
||||||
|
<span class="badge">{{ current_model.label }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for model in other_models %}
|
{% endif %}
|
||||||
<option value="{{ model.model_id }}"
|
|
||||||
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
{% if pinned_models %}</optgroup>{% endif %}
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
{% elif current_model %}
|
|
||||||
<span class="badge">{{ current_model.label }}</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button class="btn btn--icon" type="button" aria-label="Chat settings"
|
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
|
||||||
title="Chat settings"
|
<button class="btn btn--icon" type="button" aria-label="Chat settings"
|
||||||
onclick="document.getElementById('chat-settings').toggleAttribute('hidden')">
|
title="Chat settings" data-toggle="#chat-settings">
|
||||||
{{ icon("sliders") }}
|
{{ icon("sliders") }}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<h1 class="topbar__title">Chats</h1>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
|
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
|
||||||
{# Collapsed by default: these are per-chat overrides, not everyday controls.
|
{# Collapsed by default: per-chat overrides, not everyday controls. Each
|
||||||
Each field saves on change rather than needing a Save button, so there is
|
field saves on change, so there is no half-applied state. #}
|
||||||
no half-applied state to reason about. #}
|
<section class="panel" id="chat-settings" hidden>
|
||||||
<section class="chat-settings" id="chat-settings" hidden>
|
<div class="panel__inner">
|
||||||
<div class="chat-settings__inner">
|
|
||||||
{% if current_model and current_model.description %}
|
{% if current_model and current_model.description %}
|
||||||
<p class="chat-settings__note">{{ current_model.description }}</p>
|
<p class="panel__note">{{ current_model.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can.get("chat.system_prompt") %}
|
{% if can.get("chat.system_prompt") %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="system-prompt">System prompt</label>
|
<label class="field__label" for="system-prompt">System prompt</label>
|
||||||
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="3"
|
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="3"
|
||||||
placeholder="Instructions that apply to every message in this chat."
|
placeholder="{{ inherited_prompt or 'Instructions that apply to every message in this chat.' }}"
|
||||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||||
hx-trigger="change">{{ chat.system_prompt }}</textarea>
|
hx-trigger="change">{{ chat.system_prompt }}</textarea>
|
||||||
|
<p class="field__hint">
|
||||||
|
{% if inherited_prompt %}
|
||||||
|
Leave empty to use the {{ inherited_from }} prompt shown above.
|
||||||
|
{% else %}
|
||||||
|
Overrides the model and instance prompts for this chat only.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can.get("chat.params") %}
|
{% if can.get("chat.params") %}
|
||||||
<div class="chat-settings__params">
|
<div class="grid grid--3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="temperature">Temperature</label>
|
<label class="field__label" for="temperature">Temperature</label>
|
||||||
<input class="input" id="temperature" name="temperature" type="number"
|
<input class="input" id="temperature" name="temperature" type="number"
|
||||||
min="0" max="2" step="0.05" placeholder="default"
|
min="0" max="2" step="0.05" placeholder="default"
|
||||||
value="{{ chat.params_json.get('temperature', '') }}"
|
value="{{ chat.params_json.get('temperature') if chat.params_json.get('temperature') is not none else '' }}"
|
||||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="top-p">Top-p</label>
|
<label class="field__label" for="top-p">Top-p</label>
|
||||||
<input class="input" id="top-p" name="top_p" type="number"
|
<input class="input" id="top-p" name="top_p" type="number"
|
||||||
min="0" max="1" step="0.05" placeholder="default"
|
min="0" max="1" step="0.05" placeholder="default"
|
||||||
value="{{ chat.params_json.get('top_p', '') }}"
|
value="{{ chat.params_json.get('top_p') if chat.params_json.get('top_p') is not none else '' }}"
|
||||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="max-tokens">Max tokens</label>
|
<label class="field__label" for="max-tokens">Max tokens</label>
|
||||||
<input class="input" id="max-tokens" name="max_tokens" type="number"
|
<input class="input" id="max-tokens" name="max_tokens" type="number"
|
||||||
min="1" step="1" placeholder="default"
|
min="1" step="1" placeholder="default"
|
||||||
value="{{ chat.params_json.get('max_tokens', '') }}"
|
value="{{ chat.params_json.get('max_tokens') if chat.params_json.get('max_tokens') is not none else '' }}"
|
||||||
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,22 +120,8 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not chat %}
|
{% if not models %}
|
||||||
{# No chat selected. #}
|
{# Every fresh install lands here, so it points at the fix. #}
|
||||||
<div class="empty">
|
|
||||||
{{ mark(cls="empty__mark", uid="empty") }}
|
|
||||||
<h2 class="empty__title">The road goes ever on</h2>
|
|
||||||
<p class="empty__text">
|
|
||||||
Pick a chat from the side, or start a new one.
|
|
||||||
</p>
|
|
||||||
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
|
|
||||||
{{ icon("plus", "icon--sm") }} New chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif not models %}
|
|
||||||
{# Nothing to talk to yet. This is the state every fresh install lands in,
|
|
||||||
so it points straight at the fix rather than just reporting a problem. #}
|
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
{{ icon("server", "empty__mark") }}
|
{{ icon("server", "empty__mark") }}
|
||||||
<h2 class="empty__title">No models available</h2>
|
<h2 class="empty__title">No models available</h2>
|
||||||
@@ -164,71 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
|
{% include "chat/_composer.html" %}
|
||||||
{% if can.get("files.upload") %}
|
|
||||||
{# Uploads go up as soon as a file is chosen, so the chip (and any
|
|
||||||
rejection) appears immediately rather than at send time. The chips
|
|
||||||
carry hidden inputs, which is how the ids reach the message POST. #}
|
|
||||||
<form id="upload-form" hx-post="/api/files?chat_id={{ chat.id }}"
|
|
||||||
hx-target="#attachments" hx-swap="beforeend"
|
|
||||||
hx-encoding="multipart/form-data"
|
|
||||||
hx-on::after-request="this.reset()">
|
|
||||||
<input class="visually-hidden" type="file" name="file" id="file-input"
|
|
||||||
multiple accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
|
|
||||||
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="composer__attachments" id="attachments"></div>
|
|
||||||
|
|
||||||
<form class="composer__form"
|
|
||||||
hx-post="/api/chats/{{ chat.id }}/messages"
|
|
||||||
hx-target="#thread" hx-swap="beforeend"
|
|
||||||
hx-on::after-request="if (event.detail.successful) {
|
|
||||||
this.reset();
|
|
||||||
document.getElementById('attachments').replaceChildren();
|
|
||||||
const t = this.querySelector('textarea');
|
|
||||||
window.lembas.autosize(t);
|
|
||||||
window.lembas.scrollThread(true);
|
|
||||||
}">
|
|
||||||
{# The chips live outside this form, so their hidden inputs are pulled
|
|
||||||
in explicitly at submit time. #}
|
|
||||||
<div hx-include="#attachments" hidden></div>
|
|
||||||
|
|
||||||
{% if can.get("files.upload") %}
|
|
||||||
<button class="btn btn--icon composer__attach" type="button"
|
|
||||||
aria-label="Attach a file" title="Attach a file"
|
|
||||||
onclick="document.getElementById('file-input').click()">
|
|
||||||
{{ icon("attach") }}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<textarea class="composer__input" name="content" rows="1"
|
|
||||||
data-autosize data-max-height="320" data-composer-input
|
|
||||||
placeholder="Send a message…" aria-label="Message"></textarea>
|
|
||||||
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
|
|
||||||
{{ icon("send", "icon--sm") }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="composer__hint">
|
|
||||||
Enter to send, Shift+Enter for a new line.
|
|
||||||
{% if can.get("files.upload") %}
|
|
||||||
Drag files in, or paste an image.
|
|
||||||
{% if current_model and not current_model.capabilities_json.get("vision") %}
|
|
||||||
<strong>{{ current_model.label }} has no vision</strong>, so images
|
|
||||||
will not be sent — documents still will.
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if can.get("files.upload") %}
|
|
||||||
<div class="dropzone-overlay" aria-hidden="true">
|
|
||||||
{{ icon("attach", "icon--lg") }}
|
|
||||||
<span>Drop to attach</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
{% from "_macros.html" import icon, brand %}
|
{% from "_macros.html" import icon, brand, model_avatar %}
|
||||||
{#
|
{#
|
||||||
Sidebar: brand, new chat, the folder tree, then unfiled chats.
|
Sidebar: brand, new chat, pinned models, the folder tree, then unfiled chats.
|
||||||
|
|
||||||
Folders render recursively through _folder.html. Chats appear under their
|
"New chat" is a link, not a button that creates a row. The chat is written
|
||||||
folder, and any chat without one falls to the flat list at the bottom.
|
when the first message is sent, so opening one and walking away leaves
|
||||||
|
nothing behind.
|
||||||
#}
|
#}
|
||||||
<aside class="sidebar" id="sidebar" x-data="{ }">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar__header">
|
<div class="sidebar__header">
|
||||||
{{ brand(uid="side") }}
|
{{ brand(uid="side") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Buttons for actions the user cannot perform are omitted rather than
|
|
||||||
disabled: a greyed-out control invites a support question, an absent one
|
|
||||||
does not. The routes enforce the same permissions regardless. #}
|
|
||||||
{% if can.get("chat.create") or can.get("folder.manage") %}
|
{% if can.get("chat.create") or can.get("folder.manage") %}
|
||||||
<div class="sidebar__actions">
|
<div class="sidebar__actions">
|
||||||
{% if can.get("chat.create") %}
|
{% if can.get("chat.create") %}
|
||||||
<button class="btn btn--primary btn--block" hx-post="/api/chats" hx-swap="none">
|
<a class="btn btn--primary btn--grow" href="/chat">
|
||||||
{{ icon("plus", "icon--sm") }} New chat
|
{{ icon("plus", "icon--sm") }} New chat
|
||||||
</button>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can.get("folder.manage") %}
|
{% if can.get("folder.manage") %}
|
||||||
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
|
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
|
||||||
@@ -30,6 +28,20 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
|
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
|
||||||
|
{% if pinned_models and can.get("chat.create") %}
|
||||||
|
{# Shortcuts to start a chat with a particular model. These link rather than
|
||||||
|
post, so no chat exists until something is actually said. #}
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group__label">Pinned models</div>
|
||||||
|
{% for model in pinned_models %}
|
||||||
|
<a class="nav-item nav-item--model" href="/chat?model={{ model.model_id }}">
|
||||||
|
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||||
|
<span class="nav-item__label">{{ model.label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if folders %}
|
{% if folders %}
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-group__label">Folders</div>
|
<div class="nav-group__label">Folders</div>
|
||||||
@@ -52,14 +64,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar__footer">
|
<div class="sidebar__footer">
|
||||||
<div class="row row--between">
|
<a class="nav-item" href="/settings">
|
||||||
<a class="nav-item" href="/settings" style="flex: 1">
|
{{ icon("user", "icon--sm") }}
|
||||||
{{ icon("user", "icon--sm") }}
|
<span class="nav-item__label">{{ user.name }}</span>
|
||||||
<span class="nav-item__label">{{ user.name }}</span>
|
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
|
||||||
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
<div class="sidebar__tools">
|
||||||
<div class="row" style="gap: var(--sp-1); margin-top: var(--sp-1)">
|
|
||||||
{% if user.is_admin %}
|
{% if user.is_admin %}
|
||||||
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
|
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
|
||||||
{{ icon("shield") }}
|
{{ icon("shield") }}
|
||||||
@@ -70,7 +81,7 @@
|
|||||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||||
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="topbar__spacer"></span>
|
<span class="spacer"></span>
|
||||||
<form method="post" action="/auth/logout">
|
<form method="post" action="/auth/logout">
|
||||||
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
|
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
|
||||||
{{ icon("logout") }}
|
{{ icon("logout") }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_macros.html" import icon %}
|
{% from "_macros.html" import icon, model_avatar %}
|
||||||
|
|
||||||
{% block title %}Your settings - LLeMbas{% endblock %}
|
{% block title %}Your settings - LLeMbas{% endblock %}
|
||||||
|
|
||||||
@@ -19,135 +19,198 @@
|
|||||||
<h1 class="topbar__title">Your settings</h1>
|
<h1 class="topbar__title">Your settings</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="admin-scroll">
|
{#
|
||||||
<div class="admin-page">
|
Tabs are radio inputs plus sibling selectors: no JavaScript, and the
|
||||||
{% if error %}
|
chosen tab survives a re-render because the browser keeps the checked
|
||||||
<div class="alert alert--error" role="alert">
|
state. Each panel is a real fragment of the page, not a fetch.
|
||||||
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
|
#}
|
||||||
</div>
|
<div class="tabs">
|
||||||
{% endif %}
|
<div class="tabs__bar" role="tablist">
|
||||||
{% if saved %}
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-account" checked>
|
||||||
<div class="alert alert--success" role="status">
|
<label class="tabs__tab" for="tab-account">{{ icon("user", "icon--sm") }} Account</label>
|
||||||
{{ icon("check", "icon--sm") }} <span>{{ saved }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="card">
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-models">
|
||||||
<h2 class="card__title">Account</h2>
|
<label class="tabs__tab" for="tab-models">{{ icon("sliders", "icon--sm") }} Models</label>
|
||||||
<div class="field">
|
|
||||||
<span class="field__label">Name</span>
|
|
||||||
<p>{{ user.name }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="field__label">Email</span>
|
|
||||||
<p class="mono text-sm">{{ user.email }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="field__label">Role</span>
|
|
||||||
<p>
|
|
||||||
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-appearance">
|
||||||
<h2 class="card__title">Default model</h2>
|
<label class="tabs__tab" for="tab-appearance">{{ icon("sun", "icon--sm") }} Appearance</label>
|
||||||
{% if models %}
|
|
||||||
<form method="post" action="/api/preferences/default-model">
|
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-security">
|
||||||
<div class="field">
|
<label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label>
|
||||||
<select class="select" name="model_id" aria-label="Default model">
|
</div>
|
||||||
<option value="">Use the instance default</option>
|
|
||||||
{% for model in models %}
|
<div class="tabs__body">
|
||||||
<option value="{{ model.model_id }}"
|
<div class="page">
|
||||||
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
|
{% if error %}
|
||||||
{{ model.label }}{% if model.pinned %} — pinned{% endif %}
|
<div class="alert alert--error" role="alert">
|
||||||
</option>
|
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if saved %}
|
||||||
|
<div class="alert alert--success" role="status">
|
||||||
|
{{ icon("check", "icon--sm") }} <span>{{ saved }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- Account --- #}
|
||||||
|
<section class="tabs__panel" data-tab="tab-account">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card__title">Account</h2>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<dt>Name</dt><dd>{{ user.name }}</dd>
|
||||||
|
<dt>Email</dt><dd class="mono">{{ user.email }}</dd>
|
||||||
|
<dt>Role</dt>
|
||||||
|
<dd><span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span></dd>
|
||||||
|
<dt>Groups</dt>
|
||||||
|
<dd>
|
||||||
|
{% if user.groups %}
|
||||||
|
{% for group in user.groups %}<span class="badge">{{ group.name }}</span> {% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="faint">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card__title">What you can do</h2>
|
||||||
|
<p class="card__lede">
|
||||||
|
{% if user.is_admin %}
|
||||||
|
You are an administrator, so every permission applies.
|
||||||
|
{% else %}
|
||||||
|
From the instance baseline plus whatever your groups add.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<ul class="perm-list">
|
||||||
|
{% for key, granted in can.items() %}
|
||||||
|
<li class="perm-list__item">
|
||||||
|
<span class="perm-list__state {{ 'is-on' if granted }}">
|
||||||
|
{{ icon("check" if granted else "x", "icon--sm") }}
|
||||||
|
</span>
|
||||||
|
<code>{{ key }}</code>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</ul>
|
||||||
<p class="field__hint">What a new chat starts with. Existing chats keep their model.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" type="submit">Save</button>
|
</section>
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted text-sm">No models are available to you yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
{# --- Models --- #}
|
||||||
<h2 class="card__title">Permissions</h2>
|
<section class="tabs__panel" data-tab="tab-models">
|
||||||
<p class="text-sm muted" style="margin-bottom: var(--sp-3)">
|
<div class="card">
|
||||||
{% if user.is_admin %}
|
<h2 class="card__title">Default model</h2>
|
||||||
You are an administrator, so every permission applies.
|
<p class="card__lede">
|
||||||
{% else %}
|
What a new chat starts with. Existing chats keep their model.
|
||||||
What this account may do, from the instance baseline plus your groups.
|
</p>
|
||||||
|
{% if models %}
|
||||||
|
<form method="post" action="/api/preferences/default-model">
|
||||||
|
<div class="field">
|
||||||
|
<select class="select" name="model_id" aria-label="Default model">
|
||||||
|
<option value="">Use the instance default</option>
|
||||||
|
{% for model in models %}
|
||||||
|
<option value="{{ model.model_id }}"
|
||||||
|
{{ 'selected' if model.model_id == user.settings_json.get('default_model') }}>
|
||||||
|
{{ model.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted text-sm">No models are available to you yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if models %}
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card__title">Available to you</h2>
|
||||||
|
<p class="card__lede">In the order an administrator arranged them.</p>
|
||||||
|
<ul class="model-list">
|
||||||
|
{% for model in models %}
|
||||||
|
<li class="model-list__item">
|
||||||
|
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||||
|
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||||
|
<div style="min-width: 0">
|
||||||
|
<strong>{{ model.label }}</strong>
|
||||||
|
{% if model.description %}
|
||||||
|
<div class="text-xs faint">{{ model.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
{% for name, on in (model.capabilities_json or {}).items() %}
|
||||||
|
{% if on %}<span class="badge badge--gold">{{ name }}</span>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</section>
|
||||||
<div class="checkbox-row">
|
|
||||||
{% for key, granted in can.items() %}
|
|
||||||
<span class="badge {{ 'badge--success' if granted }}">
|
|
||||||
{{ key }}{{ '' if granted else ' — no' }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% if user.groups %}
|
|
||||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
|
||||||
Groups: {% for group in user.groups %}{{ group.name }}{% if not loop.last %}, {% endif %}{% endfor %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
{# --- Appearance --- #}
|
||||||
<h2 class="card__title">Appearance</h2>
|
<section class="tabs__panel" data-tab="tab-appearance">
|
||||||
<div class="row" style="gap: var(--sp-3)">
|
<div class="card">
|
||||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
|
<h2 class="card__title">Theme</h2>
|
||||||
{{ icon("moon", "icon--sm") }} Moria
|
<p class="card__lede">
|
||||||
</button>
|
Saved to this browser and to your account, so it follows you.
|
||||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
</p>
|
||||||
{{ icon("sun", "icon--sm") }} Shire
|
<div class="btn-row">
|
||||||
</button>
|
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
|
||||||
</div>
|
{{ icon("moon", "icon--sm") }} Moria — dark
|
||||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
</button>
|
||||||
Moria is the dark theme, Shire the light one. Your choice is saved
|
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
||||||
to this browser and to your account.
|
{{ icon("sun", "icon--sm") }} Shire — light
|
||||||
</p>
|
</button>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
{# --- Security --- #}
|
||||||
<h2 class="card__title">Change password</h2>
|
<section class="tabs__panel" data-tab="tab-security">
|
||||||
<form method="post" action="/api/preferences/password">
|
<div class="card">
|
||||||
<div class="field">
|
<h2 class="card__title">Change password</h2>
|
||||||
<label class="field__label" for="current-password">Current password</label>
|
<p class="card__lede">
|
||||||
<input class="input" type="password" id="current-password"
|
Every other session is signed out when the password changes.
|
||||||
name="current_password" required autocomplete="current-password">
|
You stay signed in here.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/api/preferences/password">
|
||||||
|
<div class="grid grid--2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="current-password">Current password</label>
|
||||||
|
<input class="input" type="password" id="current-password"
|
||||||
|
name="current_password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="field"></div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="new-password">New password</label>
|
||||||
|
<input class="input" type="password" id="new-password" name="new_password"
|
||||||
|
required minlength="8" autocomplete="new-password">
|
||||||
|
<p class="field__hint">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="confirm-password">Confirm new password</label>
|
||||||
|
<input class="input" type="password" id="confirm-password"
|
||||||
|
name="confirm_password" required minlength="8"
|
||||||
|
autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" type="submit">Change password</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label class="field__label" for="new-password">New password</label>
|
|
||||||
<input class="input" type="password" id="new-password" name="new_password"
|
|
||||||
required minlength="8" autocomplete="new-password">
|
|
||||||
<p class="field__hint">At least 8 characters.</p>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="field__label" for="confirm-password">Confirm new password</label>
|
|
||||||
<input class="input" type="password" id="confirm-password"
|
|
||||||
name="confirm_password" required minlength="8"
|
|
||||||
autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn--primary" type="submit">Change password</button>
|
|
||||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
|
||||||
Every other session is signed out when the password changes. You
|
|
||||||
stay signed in here.
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
<div class="card">
|
||||||
<h2 class="card__title">Session</h2>
|
<h2 class="card__title">Session</h2>
|
||||||
<form method="post" action="/auth/logout">
|
<form method="post" action="/auth/logout">
|
||||||
<button class="btn btn--danger" type="submit">
|
<button class="btn btn--danger" type="submit">
|
||||||
{{ icon("logout", "icon--sm") }} Sign out
|
{{ icon("logout", "icon--sm") }} Sign out
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -83,6 +83,43 @@ def registered(client: TestClient) -> dict[str, str]:
|
|||||||
return credentials
|
return credentials
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_chat(db: Session):
|
||||||
|
"""Create a chat row directly, as scaffolding for other tests.
|
||||||
|
|
||||||
|
Chats are normally created by POST /api/chats/start along with their first
|
||||||
|
exchange -- there is deliberately no endpoint that makes an empty one. Most
|
||||||
|
tests want a chat to act on, not that flow, so they get one straight from
|
||||||
|
the database rather than having to subtract an opening turn from every
|
||||||
|
assertion. The flow itself is covered in test_chat.py.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from lembas.db.models import Chat, Model, User
|
||||||
|
|
||||||
|
def _create(email: str | None = None, model_id: str | None = None) -> str:
|
||||||
|
user = (
|
||||||
|
db.scalar(select(User).where(User.email == email))
|
||||||
|
if email
|
||||||
|
else db.scalars(select(User).order_by(User.created_at)).first()
|
||||||
|
)
|
||||||
|
model = (
|
||||||
|
db.scalar(select(Model).where(Model.model_id == model_id))
|
||||||
|
if model_id
|
||||||
|
else db.scalars(select(Model).order_by(Model.position)).first()
|
||||||
|
)
|
||||||
|
chat = Chat(
|
||||||
|
user_id=user.id,
|
||||||
|
model_id=model.model_id if model else "",
|
||||||
|
connection_id=model.connection_id if model else None,
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
return chat.id
|
||||||
|
|
||||||
|
return _create
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def user_id(db: Session, registered: dict[str, str]) -> str:
|
def user_id(db: Session, registered: dict[str, str]) -> str:
|
||||||
"""The registered user's id.
|
"""The registered user's id.
|
||||||
|
|||||||
+3
-1
@@ -101,7 +101,9 @@ def test_htmx_requests_get_a_redirect_header_not_a_login_page(
|
|||||||
):
|
):
|
||||||
"""An htmx request must never swap a login form into a fragment of the UI."""
|
"""An htmx request must never swap a login form into a fragment of the UI."""
|
||||||
client.post("/auth/logout", follow_redirects=False)
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
response = client.post("/api/chats", headers={"HX-Request": "true"})
|
response = client.post(
|
||||||
|
"/api/chats/start", data={"content": "hi"}, headers={"HX-Request": "true"}
|
||||||
|
)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert response.headers["HX-Redirect"] == "/auth/login"
|
assert response.headers["HX-Redirect"] == "/auth/login"
|
||||||
|
|
||||||
|
|||||||
+59
-18
@@ -144,22 +144,61 @@ def _add_connection(db) -> Connection:
|
|||||||
return connection
|
return connection
|
||||||
|
|
||||||
|
|
||||||
def test_new_chat_redirects_to_its_own_url(client: TestClient, db, registered):
|
# --- Starting a chat ---------------------------------------------------------
|
||||||
|
def test_starting_a_chat_creates_it_and_redirects(client: TestClient, db, registered):
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
response = client.post("/api/chats", headers={"HX-Request": "true"})
|
response = client.post("/api/chats/start", data={"content": "Hello there"})
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert response.headers["HX-Redirect"].startswith("/chat/")
|
assert response.headers["HX-Redirect"].startswith("/chat/")
|
||||||
|
|
||||||
|
chat = db.scalar(select(Chat))
|
||||||
|
assert chat.model_id == "test-model"
|
||||||
|
messages = db.scalars(select(Message).order_by(Message.created_at)).all()
|
||||||
|
assert [m.role for m in messages] == ["user", "assistant"]
|
||||||
|
assert messages[0].content == "Hello there"
|
||||||
|
|
||||||
def test_new_chat_picks_up_the_default_model(client: TestClient, db, registered):
|
|
||||||
|
def test_starting_with_nothing_creates_no_chat(client: TestClient, db, registered):
|
||||||
|
"""The whole point of lazy creation: an abandoned composer leaves nothing."""
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
client.post("/api/chats", headers={"HX-Request": "true"})
|
assert client.post("/api/chats/start", data={"content": " "}).status_code == 204
|
||||||
|
assert db.scalar(select(Chat)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_visiting_the_chat_page_creates_nothing(client: TestClient, db, registered):
|
||||||
|
_add_connection(db)
|
||||||
|
assert client.get("/chat").status_code == 200
|
||||||
|
assert db.scalar(select(Chat)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_a_chat_honours_the_requested_model(client: TestClient, db, registered):
|
||||||
|
"""The pinned-model shortcuts pass ?model=, which arrives here."""
|
||||||
|
connection = _add_connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="other-model", position=5))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.post("/api/chats/start", data={"content": "hi", "model_id": "other-model"})
|
||||||
|
assert db.scalar(select(Chat)).model_id == "other-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_starting_a_chat_ignores_a_model_you_cannot_reach(client: TestClient, db, registered):
|
||||||
|
connection = _add_connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="secret", position=5, public=False))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
client.post("/api/chats/start", data={"content": "hi", "model_id": "secret"})
|
||||||
assert db.scalar(select(Chat)).model_id == "test-model"
|
assert db.scalar(select(Chat)).model_id == "test-model"
|
||||||
|
|
||||||
|
|
||||||
def test_posting_a_message_stores_both_turns(client: TestClient, db, registered):
|
def test_posting_a_message_stores_both_turns(client: TestClient, db, registered, make_chat):
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
|
|
||||||
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello there"})
|
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello there"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -174,16 +213,18 @@ def test_posting_a_message_stores_both_turns(client: TestClient, db, registered)
|
|||||||
assert "sse-connect" in response.text
|
assert "sse-connect" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_empty_message_is_ignored(client: TestClient, db, registered):
|
def test_empty_message_is_ignored(client: TestClient, db, registered, make_chat):
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
assert client.post(f"/api/chats/{chat_id}/messages", data={"content": " "}).status_code == 204
|
assert client.post(f"/api/chats/{chat_id}/messages", data={"content": " "}).status_code == 204
|
||||||
assert db.scalar(select(Message)) is None
|
assert db.scalar(select(Message)) is None
|
||||||
|
|
||||||
|
|
||||||
def test_a_chat_belonging_to_someone_else_is_not_found(client: TestClient, db, registered):
|
def test_a_chat_belonging_to_someone_else_is_not_found(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
|
|
||||||
client.post("/auth/logout", follow_redirects=False)
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
client.post(
|
client.post(
|
||||||
@@ -195,9 +236,9 @@ def test_a_chat_belonging_to_someone_else_is_not_found(client: TestClient, db, r
|
|||||||
assert client.get(f"/chat/{chat_id}").status_code == 404
|
assert client.get(f"/chat/{chat_id}").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, registered):
|
def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, registered, make_chat):
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
client.patch(f"/api/chats/{chat_id}", data={"title": "My own title"})
|
client.patch(f"/api/chats/{chat_id}", data={"title": "My own title"})
|
||||||
|
|
||||||
chat = db.get(Chat, chat_id)
|
chat = db.get(Chat, chat_id)
|
||||||
@@ -206,9 +247,9 @@ def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, regi
|
|||||||
assert chat.title_generated is True
|
assert chat.title_generated is True
|
||||||
|
|
||||||
|
|
||||||
def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered):
|
def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered, make_chat):
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
|
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
|
||||||
|
|
||||||
client.delete(f"/api/chats/{chat_id}")
|
client.delete(f"/api/chats/{chat_id}")
|
||||||
@@ -216,13 +257,13 @@ def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered
|
|||||||
assert db.scalar(select(Message)) is None
|
assert db.scalar(select(Message)) is None
|
||||||
|
|
||||||
|
|
||||||
def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, registered):
|
def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, registered, make_chat):
|
||||||
"""Losing a conversation to a mis-clicked folder delete is unforgivable."""
|
"""Losing a conversation to a mis-clicked folder delete is unforgivable."""
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
client.post("/api/folders", data={"name": "Quests"})
|
client.post("/api/folders", data={"name": "Quests"})
|
||||||
folder = db.scalar(select(Folder))
|
folder = db.scalar(select(Folder))
|
||||||
|
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
|
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
|
||||||
|
|
||||||
client.delete(f"/api/folders/{folder.id}")
|
client.delete(f"/api/folders/{folder.id}")
|
||||||
@@ -296,10 +337,10 @@ def test_system_prompt_leads_the_message_list(db, user_id):
|
|||||||
|
|
||||||
def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
|
def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
|
||||||
client: TestClient, db, registered
|
client: TestClient, db, registered
|
||||||
):
|
, make_chat):
|
||||||
"""A failed turn must never be an unexplained blank bubble."""
|
"""A failed turn must never be an unexplained blank bubble."""
|
||||||
_add_connection(db) # points at 127.0.0.1:1, which refuses connections
|
_add_connection(db) # points at 127.0.0.1:1, which refuses connections
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
|
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
|
||||||
message = db.scalar(select(Message).where(Message.role == "assistant"))
|
message = db.scalar(select(Message).where(Message.role == "assistant"))
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -83,7 +83,7 @@ def pdf_bytes(pages: list[str]) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def chat_with_model(client: TestClient, db, registered):
|
def chat_with_model(client: TestClient, db, registered, make_chat):
|
||||||
"""A chat whose model has vision turned on."""
|
"""A chat whose model has vision turned on."""
|
||||||
connection = Connection(name="T", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
|
connection = Connection(name="T", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
|
||||||
db.add(connection)
|
db.add(connection)
|
||||||
@@ -96,8 +96,7 @@ def chat_with_model(client: TestClient, db, registered):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
return make_chat()
|
||||||
return chat_id
|
|
||||||
|
|
||||||
|
|
||||||
# --- Type detection and processing -------------------------------------------
|
# --- Type detection and processing -------------------------------------------
|
||||||
@@ -326,7 +325,9 @@ def test_a_truly_empty_message_is_still_ignored(client: TestClient, db, chat_wit
|
|||||||
assert db.scalar(select(Message)) is None
|
assert db.scalar(select(Message)) is None
|
||||||
|
|
||||||
|
|
||||||
def test_you_cannot_attach_someone_elses_file(client: TestClient, db, chat_with_model):
|
def test_you_cannot_attach_someone_elses_file(
|
||||||
|
client: TestClient, db, chat_with_model, make_chat
|
||||||
|
):
|
||||||
"""A forged id must not pull another user's file into a conversation."""
|
"""A forged id must not pull another user's file into a conversation."""
|
||||||
client.post("/api/files", files={"file": ("mine.txt", b"secret", "text/plain")})
|
client.post("/api/files", files={"file": ("mine.txt", b"secret", "text/plain")})
|
||||||
stolen = db.scalar(select(Attachment))
|
stolen = db.scalar(select(Attachment))
|
||||||
@@ -337,7 +338,7 @@ def test_you_cannot_attach_someone_elses_file(client: TestClient, db, chat_with_
|
|||||||
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
their_chat = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
their_chat = make_chat(email="sam@shire.test")
|
||||||
client.post(
|
client.post(
|
||||||
f"/api/chats/{their_chat}/messages",
|
f"/api/chats/{their_chat}/messages",
|
||||||
data={"content": "gimme", "file_ids": [stolen.id]},
|
data={"content": "gimme", "file_ids": [stolen.id]},
|
||||||
|
|||||||
+135
-19
@@ -99,7 +99,7 @@ def test_a_group_can_grant_back_what_the_baseline_removed(db, plain_user):
|
|||||||
# --- Enforcement through the API ---------------------------------------------
|
# --- Enforcement through the API ---------------------------------------------
|
||||||
def test_creating_a_chat_is_refused_without_permission(client: TestClient, db, plain_user):
|
def test_creating_a_chat_is_refused_without_permission(client: TestClient, db, plain_user):
|
||||||
settings_store.update(db, {"default_permissions": {"chat.create": False}})
|
settings_store.update(db, {"default_permissions": {"chat.create": False}})
|
||||||
assert client.post("/api/chats").status_code == 403
|
assert client.post("/api/chats/start", data={"content": "hi"}).status_code == 403
|
||||||
assert db.scalar(select(Chat)) is None
|
assert db.scalar(select(Chat)) is None
|
||||||
|
|
||||||
|
|
||||||
@@ -108,19 +108,21 @@ def test_folder_routes_are_refused_without_permission(client: TestClient, db, pl
|
|||||||
assert client.post("/api/folders", data={"name": "Nope"}).status_code == 403
|
assert client.post("/api/folders", data={"name": "Nope"}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_changing_sampling_is_refused_without_permission(client: TestClient, db, plain_user):
|
def test_changing_sampling_is_refused_without_permission(
|
||||||
|
client: TestClient, db, plain_user, make_chat
|
||||||
|
):
|
||||||
_model(db, "test-model")
|
_model(db, "test-model")
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat(email="sam@shire.test")
|
||||||
response = client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"})
|
response = client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"})
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_sampling_is_allowed_once_a_group_grants_it(client: TestClient, db, plain_user):
|
def test_sampling_is_allowed_once_a_group_grants_it(client: TestClient, db, plain_user, make_chat):
|
||||||
_model(db, "test-model")
|
_model(db, "test-model")
|
||||||
db.add(Group(name="Tuners", permissions_json={"chat.params": True}, users=[plain_user]))
|
db.add(Group(name="Tuners", permissions_json={"chat.params": True}, users=[plain_user]))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat(email="sam@shire.test")
|
||||||
assert client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"}).status_code == 204
|
assert client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"}).status_code == 204
|
||||||
|
|
||||||
chat = db.get(Chat, chat_id)
|
chat = db.get(Chat, chat_id)
|
||||||
@@ -134,10 +136,10 @@ def test_sampling_is_allowed_once_a_group_grants_it(client: TestClient, db, plai
|
|||||||
)
|
)
|
||||||
def test_out_of_range_parameters_are_dropped_not_clamped(
|
def test_out_of_range_parameters_are_dropped_not_clamped(
|
||||||
client: TestClient, db, registered, field, value
|
client: TestClient, db, registered, field, value
|
||||||
):
|
, make_chat):
|
||||||
"""Silently changing what someone typed is worse than ignoring it."""
|
"""Silently changing what someone typed is worse than ignoring it."""
|
||||||
_model(db, "test-model")
|
_model(db, "test-model")
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
client.patch(f"/api/chats/{chat_id}", data={field: value})
|
client.patch(f"/api/chats/{chat_id}", data={field: value})
|
||||||
|
|
||||||
chat = db.get(Chat, chat_id)
|
chat = db.get(Chat, chat_id)
|
||||||
@@ -145,9 +147,9 @@ def test_out_of_range_parameters_are_dropped_not_clamped(
|
|||||||
assert field not in (chat.params_json or {})
|
assert field not in (chat.params_json or {})
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_parameter_clears_it(client: TestClient, db, registered):
|
def test_an_empty_parameter_clears_it(client: TestClient, db, registered, make_chat):
|
||||||
_model(db, "test-model")
|
_model(db, "test-model")
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.7"})
|
client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.7"})
|
||||||
client.patch(f"/api/chats/{chat_id}", data={"temperature": ""})
|
client.patch(f"/api/chats/{chat_id}", data={"temperature": ""})
|
||||||
|
|
||||||
@@ -187,12 +189,14 @@ def test_disabled_models_are_hidden_from_everyone(db, registered):
|
|||||||
assert permissions.models_visible_to(db, admin_user) == []
|
assert permissions.models_visible_to(db, admin_user) == []
|
||||||
|
|
||||||
|
|
||||||
def test_switching_to_an_inaccessible_model_is_refused(client: TestClient, db, plain_user):
|
def test_switching_to_an_inaccessible_model_is_refused(
|
||||||
|
client: TestClient, db, plain_user, make_chat
|
||||||
|
):
|
||||||
"""The picker is not the security boundary; a crafted request must fail."""
|
"""The picker is not the security boundary; a crafted request must fail."""
|
||||||
_model(db, "open-model", public=True)
|
_model(db, "open-model", public=True)
|
||||||
_model(db, "secret-model", public=False)
|
_model(db, "secret-model", public=False)
|
||||||
|
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat(email="sam@shire.test")
|
||||||
response = client.patch(f"/api/chats/{chat_id}", data={"model_id": "secret-model"})
|
response = client.patch(f"/api/chats/{chat_id}", data={"model_id": "secret-model"})
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
@@ -201,17 +205,21 @@ def test_switching_to_an_inaccessible_model_is_refused(client: TestClient, db, p
|
|||||||
assert chat.model_id == "open-model"
|
assert chat.model_id == "open-model"
|
||||||
|
|
||||||
|
|
||||||
def test_model_select_permission_is_required_to_switch(client: TestClient, db, plain_user):
|
def test_model_select_permission_is_required_to_switch(
|
||||||
|
client: TestClient, db, plain_user, make_chat
|
||||||
|
):
|
||||||
_model(db, "a-model", public=True)
|
_model(db, "a-model", public=True)
|
||||||
_model(db, "b-model", public=True)
|
_model(db, "b-model", public=True)
|
||||||
settings_store.update(db, {"default_permissions": {"chat.model_select": False}})
|
settings_store.update(db, {"default_permissions": {"chat.model_select": False}})
|
||||||
|
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat(email="sam@shire.test")
|
||||||
assert client.patch(f"/api/chats/{chat_id}", data={"model_id": "b-model"}).status_code == 403
|
assert client.patch(f"/api/chats/{chat_id}", data={"model_id": "b-model"}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
# --- Ordering and defaults ---------------------------------------------------
|
# --- Ordering and defaults ---------------------------------------------------
|
||||||
def test_pinned_models_sort_first(db, registered):
|
def test_pinning_does_not_reorder_the_picker(db, registered):
|
||||||
|
"""Pinning is a sidebar shortcut. A picker whose order silently differs from
|
||||||
|
the admin screen is just confusing."""
|
||||||
connection = _connection(db)
|
connection = _connection(db)
|
||||||
db.add_all(
|
db.add_all(
|
||||||
[
|
[
|
||||||
@@ -222,8 +230,8 @@ def test_pinned_models_sort_first(db, registered):
|
|||||||
db.commit()
|
db.commit()
|
||||||
admin_user = db.scalar(select(User).where(User.role == "admin"))
|
admin_user = db.scalar(select(User).where(User.role == "admin"))
|
||||||
assert [m.model_id for m in chat_service.available_models(db, admin_user)] == [
|
assert [m.model_id for m in chat_service.available_models(db, admin_user)] == [
|
||||||
"favourite",
|
|
||||||
"ordinary",
|
"ordinary",
|
||||||
|
"favourite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -254,7 +262,7 @@ def test_instance_default_model_is_used_for_new_chats(client: TestClient, db, re
|
|||||||
db.commit()
|
db.commit()
|
||||||
settings_store.update(db, {"default_model": "chosen"})
|
settings_store.update(db, {"default_model": "chosen"})
|
||||||
|
|
||||||
client.post("/api/chats")
|
client.post("/api/chats/start", data={"content": "hi"})
|
||||||
assert db.scalar(select(Chat)).model_id == "chosen"
|
assert db.scalar(select(Chat)).model_id == "chosen"
|
||||||
|
|
||||||
|
|
||||||
@@ -270,7 +278,7 @@ def test_a_users_own_default_beats_the_instance_default(client: TestClient, db,
|
|||||||
settings_store.update(db, {"default_model": "instance-pick"})
|
settings_store.update(db, {"default_model": "instance-pick"})
|
||||||
client.post("/api/preferences/default-model", data={"model_id": "my-pick"})
|
client.post("/api/preferences/default-model", data={"model_id": "my-pick"})
|
||||||
|
|
||||||
client.post("/api/chats")
|
client.post("/api/chats/start", data={"content": "hi"})
|
||||||
assert db.scalar(select(Chat)).model_id == "my-pick"
|
assert db.scalar(select(Chat)).model_id == "my-pick"
|
||||||
|
|
||||||
|
|
||||||
@@ -346,7 +354,7 @@ def test_deactivating_a_user_revokes_their_sessions(client: TestClient, db, regi
|
|||||||
assert other.get("/chat", follow_redirects=False).status_code == 303
|
assert other.get("/chat", follow_redirects=False).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered):
|
def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered, make_chat):
|
||||||
"""Two options with the same value, both selected, is not a picker."""
|
"""Two options with the same value, both selected, is not a picker."""
|
||||||
connection = _connection(db)
|
connection = _connection(db)
|
||||||
db.add_all(
|
db.add_all(
|
||||||
@@ -357,7 +365,115 @@ def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, regis
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
|
chat_id = make_chat()
|
||||||
page = client.get(f"/chat/{chat_id}").text
|
page = client.get(f"/chat/{chat_id}").text
|
||||||
assert page.count('<option value="favourite"') == 1
|
assert page.count('<option value="favourite"') == 1
|
||||||
assert page.count('<option value="ordinary"') == 1
|
assert page.count('<option value="ordinary"') == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- System prompt layering --------------------------------------------------
|
||||||
|
def test_system_prompt_falls_back_to_the_instance(db, user_id):
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="m"))
|
||||||
|
db.commit()
|
||||||
|
settings_store.update(db, {"system_prompt": "You are terse."})
|
||||||
|
|
||||||
|
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
assert chat_service.effective_system_prompt(db, chat) == "You are terse."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_models_prompt_beats_the_instance(db, user_id):
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="m", system_prompt="You are a poet."))
|
||||||
|
db.commit()
|
||||||
|
settings_store.update(db, {"system_prompt": "You are terse."})
|
||||||
|
|
||||||
|
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
assert chat_service.effective_system_prompt(db, chat) == "You are a poet."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_chats_prompt_beats_everything(db, user_id):
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="m", system_prompt="You are a poet."))
|
||||||
|
db.commit()
|
||||||
|
settings_store.update(db, {"system_prompt": "You are terse."})
|
||||||
|
|
||||||
|
chat = Chat(
|
||||||
|
user_id=user_id, model_id="m", connection_id=connection.id,
|
||||||
|
system_prompt="You are a dwarf.",
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
assert chat_service.effective_system_prompt(db, chat) == "You are a dwarf."
|
||||||
|
|
||||||
|
|
||||||
|
def test_layers_replace_rather_than_stack(db, user_id):
|
||||||
|
"""Concatenating them reads well in a settings screen and badly in practice:
|
||||||
|
two layers that disagree give the model contradictory instructions."""
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="m", system_prompt="MODEL"))
|
||||||
|
db.commit()
|
||||||
|
settings_store.update(db, {"system_prompt": "INSTANCE"})
|
||||||
|
|
||||||
|
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
assert "INSTANCE" not in chat_service.effective_system_prompt(db, chat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_prompt_anywhere_sends_no_system_message(db, user_id):
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="m"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
assert chat_service.build_request(db, chat)["messages"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin bulk actions ------------------------------------------------------
|
||||||
|
def test_bulk_disable_works(client: TestClient, db, registered):
|
||||||
|
"""Regression: /admin/models/{model_id} was registered first, so "bulk" was
|
||||||
|
parsed as a model id and every bulk action 404'd."""
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Model(connection_id=connection.id, model_id="a"),
|
||||||
|
Model(connection_id=connection.id, model_id="b"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
ids = [m.id for m in db.scalars(select(Model))]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/admin/models/bulk", data={"action": "disable", "model_ids": ids},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert all(not m.enabled for m in db.scalars(select(Model)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_restrict_then_publish(client: TestClient, db, registered):
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="a"))
|
||||||
|
db.commit()
|
||||||
|
model = db.scalar(select(Model))
|
||||||
|
|
||||||
|
client.post("/admin/models/bulk", data={"action": "private", "model_ids": [model.id]})
|
||||||
|
db.refresh(model)
|
||||||
|
assert model.public is False
|
||||||
|
|
||||||
|
client.post("/admin/models/bulk", data={"action": "public", "model_ids": [model.id]})
|
||||||
|
db.refresh(model)
|
||||||
|
assert model.public is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_with_nothing_selected_is_harmless(client: TestClient, db, registered):
|
||||||
|
assert client.post(
|
||||||
|
"/admin/models/bulk", data={"action": "disable"}, follow_redirects=False
|
||||||
|
).status_code == 303
|
||||||
|
|||||||
Reference in New Issue
Block a user