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
+118
View File
@@ -0,0 +1,118 @@
"""The cards offered on the new-chat screen.
A blank composer is the least helpful thing a chat client can show someone who
has just installed one. These are three starting points an administrator can
replace with their own.
"""
from __future__ import annotations
import logging
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import Suggestion
from lembas.services import settings_store
log = logging.getLogger(__name__)
# A short list stays a short list. Past a dozen it is a menu, and a menu on the
# empty screen is a worse blank page than a blank page.
MAX_SUGGESTIONS = 12
MAX_SHOWN = 6
MAX_NAME = 120
MAX_DESCRIPTION = 300
MAX_PROMPT = 4000
# Each ends mid-sentence, so the caret lands exactly where the person has to
# start typing. No Middle-earth flavour: this is functional UI.
DEFAULTS: tuple[tuple[str, str, str], ...] = (
(
"Explain this",
"Paste something confusing and get it back in plain language.",
"Explain the following in plain language. Start with one sentence "
"summarising it, then the details that actually matter, then anything I "
"should watch out for. If I have not pasted anything yet, ask me for it "
"rather than guessing.\n\n",
),
(
"Draft a reply",
"Turn a message you have received into an answer you can send.",
"Help me reply to the message below. If the tone I want and the outcome "
"I am after are not obvious from it, ask me before writing. Then give me "
"a draft I could send as it stands.\n\n",
),
(
"Find the flaw",
"Have a plan argued with before you commit to it.",
"I am going to describe a plan. Argue against it: what is most likely to "
"go wrong, what am I assuming without evidence, and what would change "
"your mind. Do not soften it, and do not agree just because I sound "
"confident.\n\nMy plan: ",
),
)
# Guards the seed. Not "is the table empty", because an administrator who
# deletes all three would get them back on every restart.
SEEDED_KEY = "suggestions_seeded"
def visible(db: DBSession) -> list[Suggestion]:
"""What the new-chat screen shows, in order."""
return list(
db.scalars(
select(Suggestion)
.where(Suggestion.enabled.is_(True))
.order_by(Suggestion.position, Suggestion.name)
.limit(MAX_SHOWN)
)
)
def all_of_them(db: DBSession) -> list[Suggestion]:
"""Every suggestion, enabled or not, for the admin page."""
return list(
db.scalars(select(Suggestion).order_by(Suggestion.position, Suggestion.name))
)
def next_position(db: DBSession) -> int:
"""New rows land at the end rather than at 0, where they would shuffle.
No `or -1` after the coalesce: position 0 is falsy, so that idiom sends the
second row back to 0 on top of the first.
"""
highest = db.scalar(select(func.coalesce(func.max(Suggestion.position), -1)))
return int(highest if highest is not None else -1) + 1
def create(db: DBSession, *, name: str, description: str = "", prompt: str = "") -> Suggestion:
suggestion = Suggestion(
name=name.strip()[:MAX_NAME],
description=description.strip()[:MAX_DESCRIPTION],
prompt=prompt[:MAX_PROMPT],
position=next_position(db),
)
db.add(suggestion)
db.commit()
return suggestion
def seed_defaults(db: DBSession) -> int:
"""Write the built-in suggestions, once ever.
Runs from the startup housekeeping block. The flag is what makes it once:
checking whether the table is empty would restore all three every restart
for anyone who decided they did not want them.
"""
if settings_store.get(db, SEEDED_KEY):
return 0
for name, description, prompt in DEFAULTS:
create(db, name=name, description=description, prompt=prompt)
settings_store.update(db, {SEEDED_KEY: True})
log.info("seeded %d default suggestion(s)", len(DEFAULTS))
return len(DEFAULTS)