Prompt suggestions on the new-chat screen

A blank composer is the least helpful thing a chat client can show someone
who has just installed one. Three cards now sit under the empty state, and
an administrator manages them at /admin/suggestions.

Clicking a card fills the composer and stops there. It deliberately does not
send: every default ends mid-sentence, because a card is a starting point
rather than a question somebody already asked, and the caret lands where the
person has to start typing.

Seeding is guarded by a settings flag, not by "is the table empty" --
otherwise an administrator who decided against them would get all three back
on every restart. Capped at twelve, six shown: past a dozen this is a menu,
and a menu on the empty screen is a worse blank page than a blank page.

The cards are gated on there being no chat at all, not on the thread being
empty. An empty chat someone opened on purpose already has a model and a
prompt chosen.

Also fixes a pre-existing bug the position test caught. Both this and
_refresh_models wrote `coalesce(max(position), -1) or -1`, and position 0 is
falsy -- so the second row landed back on 0 on top of the first. The
coalesce was already doing that job; the `or` was undoing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 00:49:12 +02:00
parent 09eecbdd9a
commit 26793b1317
13 changed files with 650 additions and 1 deletions
+4 -1
View File
@@ -192,7 +192,10 @@ async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, s
# New models land after everything already ordered, rather than all at
# position 0 where they would sort by id and shuffle the existing list.
next_position = (db.scalar(select(func.coalesce(func.max(Model.position), -1))) or -1) + 1
# No `or -1` after the coalesce: position 0 is falsy, so that idiom sent the
# second discovered model back to 0 on top of the first.
highest = db.scalar(select(func.coalesce(func.max(Model.position), -1)))
next_position = int(highest if highest is not None else -1) + 1
for entry in discovered:
model_id = str(entry["id"])[:300]
+113
View File
@@ -0,0 +1,113 @@
"""Administration for the cards offered on the new-chat screen."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.db.models import Suggestion
from lembas.services import suggestions as suggestions_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/suggestions", tags=["admin-suggestions"])
def _suggestion(db: Db, suggestion_id: str) -> Suggestion:
suggestion = db.get(Suggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That suggestion no longer exists.")
return suggestion
def _back(message: str = "") -> Response:
target = f"/admin/suggestions?saved={message}" if message else "/admin/suggestions"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
@router.get("")
async def suggestions_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
rows = suggestions_service.all_of_them(db)
return render(
request,
"admin/suggestions.html",
{
"suggestions": rows,
"at_limit": len(rows) >= suggestions_service.MAX_SUGGESTIONS,
"max_suggestions": suggestions_service.MAX_SUGGESTIONS,
"max_shown": suggestions_service.MAX_SHOWN,
"saved": saved,
},
)
@router.post("")
async def create_suggestion(
db: Db,
user: AdminUser,
name: str = Form(""),
description: str = Form(""),
prompt: str = Form(""),
) -> Response:
name = name.strip()
if not name:
return _back("A suggestion needs a name.")
if len(suggestions_service.all_of_them(db)) >= suggestions_service.MAX_SUGGESTIONS:
return _back(f"That is already {suggestions_service.MAX_SUGGESTIONS}, which is plenty.")
suggestions_service.create(db, name=name, description=description, prompt=prompt)
log.info("%s added suggestion %s", user.email, name)
return _back(f"Added {name}.")
# Registered before /{suggestion_id}: FastAPI matches in registration order, so
# with the parameterised route first any literal segment added later would be
# captured as an id. That has already been a bug once, in /admin/models.
@router.post("/{suggestion_id}/delete")
async def delete_suggestion(db: Db, user: AdminUser, suggestion_id: str) -> Response:
suggestion = _suggestion(db, suggestion_id)
name = suggestion.name
db.delete(suggestion)
db.commit()
log.info("%s deleted suggestion %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/{suggestion_id}")
async def update_suggestion(
request: Request,
db: Db,
user: AdminUser,
suggestion_id: str,
) -> Response:
"""Save one row.
The raw form is read rather than declared parameters because `enabled` is a
checkbox: FastAPI cannot tell an unticked box from an absent field, and an
absent one is exactly what an unticked box sends.
"""
suggestion = _suggestion(db, suggestion_id)
form = await request.form()
suggestion.name = (
str(form.get("name") or "").strip()[: suggestions_service.MAX_NAME] or suggestion.name
)
suggestion.description = str(form.get("description") or "").strip()[
: suggestions_service.MAX_DESCRIPTION
]
suggestion.prompt = str(form.get("prompt") or "").replace("\r\n", "\n")[
: suggestions_service.MAX_PROMPT
]
suggestion.enabled = "enabled" in form
position = str(form.get("position") or "").strip()
if position.isdigit():
suggestion.position = min(max(int(position) - 1, 0), 999)
db.commit()
log.info("%s updated suggestion %s", user.email, suggestion.name)
return _back(f"Saved {suggestion.name}.")
+2
View File
@@ -13,6 +13,7 @@ from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import chat as chat_service
from lembas.services import settings_store
from lembas.services import suggestions as suggestions_service
from lembas.services.library import documents as documents_service
from lembas.services.markdown import render_markdown
from lembas.web.templating import STATIC_DIR, render
@@ -201,6 +202,7 @@ async def chat_index(
**context,
"current_model": preselected,
"starting_temporary": temporary,
"suggestions": suggestions_service.visible(db),
**sidebar_context(db, user),
},
)