From 09cfde4de8f3927cd7a45fb7857569bde8fc9571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 1 Aug 2026 00:49:12 +0200 Subject: [PATCH] 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) --- src/lembas/api/admin.py | 5 +- src/lembas/api/admin_suggestions.py | 113 +++++++++++ src/lembas/api/pages.py | 2 + src/lembas/db/models/__init__.py | 2 + src/lembas/db/models/suggestion.py | 32 ++++ src/lembas/main.py | 5 + src/lembas/services/suggestions.py | 118 ++++++++++++ src/lembas/web/static/css/chat.css | 37 ++++ src/lembas/web/static/js/app.js | 15 ++ src/lembas/web/templates/admin/_layout.html | 5 + .../web/templates/admin/suggestions.html | 120 ++++++++++++ src/lembas/web/templates/chat/index.html | 16 ++ tests/test_suggestions.py | 181 ++++++++++++++++++ 13 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 src/lembas/api/admin_suggestions.py create mode 100644 src/lembas/db/models/suggestion.py create mode 100644 src/lembas/services/suggestions.py create mode 100644 src/lembas/web/templates/admin/suggestions.html create mode 100644 tests/test_suggestions.py diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py index e0ed952..f066bea 100644 --- a/src/lembas/api/admin.py +++ b/src/lembas/api/admin.py @@ -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] diff --git a/src/lembas/api/admin_suggestions.py b/src/lembas/api/admin_suggestions.py new file mode 100644 index 0000000..3a1cae4 --- /dev/null +++ b/src/lembas/api/admin_suggestions.py @@ -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}.") diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 961b31b..d26350d 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -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), }, ) diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py index 8634c11..3aff3bd 100644 --- a/src/lembas/db/models/__init__.py +++ b/src/lembas/db/models/__init__.py @@ -41,6 +41,7 @@ from lembas.db.models.library import ( chat_knowledge_bases, ) from lembas.db.models.setting import Setting +from lembas.db.models.suggestion import Suggestion from lembas.db.models.user import ( ROLE_ADMIN, ROLE_PENDING, @@ -85,6 +86,7 @@ __all__ = [ "Share", "Skill", "SkillRevision", + "Suggestion", "User", "chat_knowledge_bases", "model_groups", diff --git a/src/lembas/db/models/suggestion.py b/src/lembas/db/models/suggestion.py new file mode 100644 index 0000000..f47bfe0 --- /dev/null +++ b/src/lembas/db/models/suggestion.py @@ -0,0 +1,32 @@ +"""Starting points offered on the new-chat screen.""" + +from __future__ import annotations + +from sqlalchemy import Boolean, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from lembas.db.base import Base, Timestamps, UUIDPrimaryKey + + +class Suggestion(UUIDPrimaryKey, Timestamps, Base): + """One card on the empty chat screen. + + Instance-wide rather than per-user: these are what an administrator wants + people to start with, the same way the instance system prompt is. There is + no owner_id and therefore nothing for `sharing` to decide. + """ + + __tablename__ = "suggestions" + + name: Mapped[str] = mapped_column(String(120), nullable=False) + description: Mapped[str] = mapped_column(String(300), default="") + # What lands in the composer. Deliberately not sent on its own: it usually + # ends mid-sentence, because a card is a starting point rather than a + # question somebody already asked. + prompt: Mapped[str] = mapped_column(Text, default="") + + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/src/lembas/main.py b/src/lembas/main.py index dcf0bed..d63fd6f 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -18,6 +18,7 @@ from lembas.api import ( admin_models, admin_prompts, admin_search, + admin_suggestions, admin_users, audio, auth, @@ -65,6 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: from lembas.services.chat import sweep_temporary from lembas.services.files import sweep_orphans from lembas.services.library.documents import sweep_unfiled + from lembas.services.suggestions import seed_defaults as seed_suggestions with session_scope() as db: sweep_orphans(db) @@ -74,6 +76,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Temporary chats older than a day. Startup only, like the sweeps # above it -- see services/chat.py:sweep_temporary. sweep_temporary(db) + # Three starting points on the empty screen, written once ever. + seed_suggestions(db) except Exception: # noqa: BLE001 - housekeeping must never block startup log.exception("orphaned upload sweep failed") @@ -115,6 +119,7 @@ def create_app() -> FastAPI: app.include_router(admin_audio.router) app.include_router(admin_search.router) app.include_router(admin_prompts.router) + app.include_router(admin_suggestions.router) register_error_handlers(app) return app diff --git a/src/lembas/services/suggestions.py b/src/lembas/services/suggestions.py new file mode 100644 index 0000000..deee617 --- /dev/null +++ b/src/lembas/services/suggestions.py @@ -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) diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 44f148c..a63a94b 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -167,6 +167,43 @@ .reasoning__summary::-webkit-details-marker { display: none; } .reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); } +/* --- Suggestions ----------------------------------------------------------- + Starting points on the empty screen. Cards rather than a list, because they + are things to press. +*/ +.suggestions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: var(--sp-3); + width: 100%; + max-width: 40rem; + margin-top: var(--sp-6); + text-align: left; +} + +.suggestion { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--ink); + cursor: pointer; + text-align: left; + transition: background var(--transition-fast), border-color var(--transition-fast); +} +.suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); } +.suggestion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.suggestion__name { font-weight: 600; font-size: var(--text-sm); } +.suggestion__note { + font-size: var(--text-xs); + color: var(--ink-muted); + line-height: var(--leading-normal); +} + /* --- Metrics --------------------------------------------------------------- What a reply cost, under the bubble. Quiet by default: it is reference, not something to read every time. diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index 230ec31..88c9b39 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -384,6 +384,21 @@ return; } + /* A suggestion card fills the composer and stops there. It deliberately + does not submit: the prompts end mid-sentence, because a card is a + starting point rather than a question somebody already asked. */ + var suggestion = event.target.closest("[data-suggestion]"); + if (suggestion) { + event.preventDefault(); + var input = document.querySelector("[data-composer-input]"); + if (!input) return; + input.value = suggestion.dataset.suggestion; + autosize(input); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + 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]"); diff --git a/src/lembas/web/templates/admin/_layout.html b/src/lembas/web/templates/admin/_layout.html index 78b90ec..b446059 100644 --- a/src/lembas/web/templates/admin/_layout.html +++ b/src/lembas/web/templates/admin/_layout.html @@ -47,6 +47,11 @@ {{ icon("sparkle", "icon--sm") }} Prompts + + {{ icon("star", "icon--sm") }} + Suggestions + {{ icon("user", "icon--sm") }} Users diff --git a/src/lembas/web/templates/admin/suggestions.html b/src/lembas/web/templates/admin/suggestions.html new file mode 100644 index 0000000..f46b062 --- /dev/null +++ b/src/lembas/web/templates/admin/suggestions.html @@ -0,0 +1,120 @@ +{% extends "admin/_layout.html" %} +{% from "_macros.html" import icon %} +{% set section = "suggestions" %} + +{% block title %}Suggestions - LLeMbas{% endblock %} +{% block heading %}Suggestions{% endblock %} + +{% block admin_content %} +

+ Cards on the new-chat screen. Clicking one puts its prompt in the composer + without sending it — the built-in ones deliberately end mid-sentence, so the + caret lands where the person has to start typing. The first + {{ max_shown }} enabled ones are shown, in this order. +

+ +{% if saved %} +
{{ icon("check", "icon--sm") }} {{ saved }}
+{% endif %} + +{% for suggestion in suggestions %} +
+
+

+ {{ suggestion.name }} + {% if not suggestion.enabled %}hidden{% endif %} + {% if loop.index > max_shown and suggestion.enabled %} + + below the cut + + {% endif %} +

+ +
+ +
+
+ + +

The heading on the card.

+
+
+ + +

Order on the screen, lowest first.

+
+
+ +
+ + +

One line under the name, saying what it is for.

+
+ +
+ + +

+ Put in the composer, not sent. Ending it mid-sentence is usually right: + the person still has to say what they are asking about. +

+
+ +
+ +
+ + +
+{% else %} +
+ {{ icon("sparkle", "empty__mark") }} +

No suggestions

+

The new-chat screen shows its empty state instead.

+
+{% endfor %} + +{% if not at_limit %} +
+

Add a suggestion

+
+ + +
+
+ + +
+
+ + +
+ +
+{% else %} +

+ {{ max_suggestions }} is the limit. Delete one to add another — past a dozen + this is a menu, and a menu on the empty screen is a worse blank page than a + blank page. +

+{% endif %} +{% endblock %} diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html index 5e6ccfa..d4d20a6 100644 --- a/src/lembas/web/templates/chat/index.html +++ b/src/lembas/web/templates/chat/index.html @@ -184,6 +184,22 @@ {{ mark(cls="empty__mark", uid="intro") }}

What would you ask?

Speak, friend, and enter.

+ + {# Only on a chat that does not exist yet. An empty chat someone + opened on purpose already has a model and a prompt chosen. #} + {% if not chat and suggestions %} +
+ {% for suggestion in suggestions %} + + {% endfor %} +
+ {% endif %} {% endif %} diff --git a/tests/test_suggestions.py b/tests/test_suggestions.py new file mode 100644 index 0000000..090c08f --- /dev/null +++ b/tests/test_suggestions.py @@ -0,0 +1,181 @@ +"""Prompt suggestions: seeding, administration, and the cards.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from lembas.db.models import Connection, Model, Suggestion, User +from lembas.services import settings_store +from lembas.services import suggestions as suggestions_service +from lembas.services.crypto import encrypt + + +@pytest.fixture +def clean(client: TestClient, db, registered): + """Start from no suggestions. + + The app's lifespan seeds the three defaults, so any test that goes through + `client` already has them. Tests about seeding want that; tests about the + admin screen want to count their own rows. + """ + for suggestion in suggestions_service.all_of_them(db): + db.delete(suggestion) + db.commit() + + +@pytest.fixture +def plain_user(client: TestClient, db, registered) -> User: + 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, + ) + return db.scalar(select(User).where(User.email == "sam@shire.test")) + + +def _connection(db) -> None: + connection = Connection( + name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("") + ) + db.add(connection) + db.commit() + db.add(Model(connection_id=connection.id, model_id="test-model")) + db.commit() + + +# --- Seeding ------------------------------------------------------------------ +def test_seeding_writes_the_defaults_once(db): + assert suggestions_service.seed_defaults(db) == len(suggestions_service.DEFAULTS) + assert suggestions_service.seed_defaults(db) == 0 + assert len(suggestions_service.all_of_them(db)) == len(suggestions_service.DEFAULTS) + + +def test_deleting_every_suggestion_does_not_bring_them_back(db): + """The guard is a flag, not "is the table empty". Otherwise an administrator + who decided against them gets them back on every restart.""" + suggestions_service.seed_defaults(db) + for suggestion in suggestions_service.all_of_them(db): + db.delete(suggestion) + db.commit() + + assert suggestions_service.seed_defaults(db) == 0 + assert suggestions_service.all_of_them(db) == [] + + +def test_the_seed_flag_lives_in_settings(db): + suggestions_service.seed_defaults(db) + assert settings_store.get(db, suggestions_service.SEEDED_KEY) is True + + +# --- What is shown ------------------------------------------------------------ +def test_only_enabled_ones_are_shown(db): + suggestions_service.create(db, name="Shown", prompt="a") + hidden = suggestions_service.create(db, name="Hidden", prompt="b") + hidden.enabled = False + db.commit() + + assert [s.name for s in suggestions_service.visible(db)] == ["Shown"] + + +def test_position_decides_the_order(db): + first = suggestions_service.create(db, name="Zebra", prompt="a") + second = suggestions_service.create(db, name="Antelope", prompt="b") + first.position, second.position = 5, 1 + db.commit() + + assert [s.name for s in suggestions_service.visible(db)] == ["Antelope", "Zebra"] + + +def test_no_more_than_the_cap_is_shown(db): + for index in range(suggestions_service.MAX_SHOWN + 3): + suggestions_service.create(db, name=f"One {index}", prompt="x") + assert len(suggestions_service.visible(db)) == suggestions_service.MAX_SHOWN + + +def test_new_rows_land_at_the_end(db): + suggestions_service.create(db, name="First", prompt="a") + assert suggestions_service.create(db, name="Second", prompt="b").position == 1 + + +# --- The cards ---------------------------------------------------------------- +def test_the_cards_appear_on_the_new_chat_screen(client: TestClient, db, registered): + _connection(db) + suggestions_service.create(db, name="Explain this", description="Plain language.", prompt="Go ") + + page = client.get("/chat").text + assert "Explain this" in page + assert "Plain language." in page + assert 'data-suggestion="Go "' in page + + +def test_the_cards_do_not_appear_in_an_existing_empty_chat( + client: TestClient, db, registered, make_chat +): + """A chat someone opened on purpose already has a model and a prompt + chosen; the cards are for the screen where nothing has been decided.""" + _connection(db) + suggestions_service.create(db, name="Explain this", prompt="Go ") + chat_id = make_chat() + + assert "data-suggestion" not in client.get(f"/chat/{chat_id}").text + + +def test_a_prompt_with_quotes_is_escaped_in_the_attribute(client: TestClient, db, registered): + _connection(db) + suggestions_service.create(db, name="Tricky", prompt='say ""') + + page = client.get("/chat").text + assert "" not in page + assert "<script>" in page + + +# --- Administration ----------------------------------------------------------- +def test_the_admin_page_is_refused_to_a_plain_user(client: TestClient, plain_user): + assert client.get("/admin/suggestions").status_code == 403 + assert client.post("/admin/suggestions", data={"name": "x"}).status_code == 403 + + +def test_an_admin_can_create_edit_and_delete(client: TestClient, db, registered, clean): + client.post( + "/admin/suggestions", + data={"name": "Explain this", "description": "Plain language.", "prompt": "Go "}, + follow_redirects=False, + ) + suggestion = db.scalar(select(Suggestion)) + assert suggestion.name == "Explain this" + assert suggestion.enabled is True + + client.post( + f"/admin/suggestions/{suggestion.id}", + data={"name": "Renamed", "description": "", "prompt": "New ", "position": "3"}, + follow_redirects=False, + ) + db.refresh(suggestion) + assert suggestion.name == "Renamed" + assert suggestion.prompt == "New " + # An unticked checkbox is simply absent from the post, which is the signal. + assert suggestion.enabled is False + assert suggestion.position == 2 + + client.post(f"/admin/suggestions/{suggestion.id}/delete", follow_redirects=False) + assert db.scalar(select(Suggestion)) is None + + +def test_a_nameless_suggestion_is_refused(client: TestClient, db, registered, clean): + client.post("/admin/suggestions", data={"name": " "}, follow_redirects=False) + assert db.scalar(select(Suggestion)) is None + + +def test_the_limit_is_enforced(client: TestClient, db, registered, clean): + for index in range(suggestions_service.MAX_SUGGESTIONS): + suggestions_service.create(db, name=f"One {index}", prompt="x") + + client.post("/admin/suggestions", data={"name": "One too many"}, follow_redirects=False) + assert len(suggestions_service.all_of_them(db)) == suggestions_service.MAX_SUGGESTIONS + + +def test_editing_something_that_is_gone_is_a_404(client: TestClient, registered): + assert client.post("/admin/suggestions/nope", data={"name": "x"}).status_code == 404