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:
@@ -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
|
# New models land after everything already ordered, rather than all at
|
||||||
# position 0 where they would sort by id and shuffle the existing list.
|
# 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:
|
for entry in discovered:
|
||||||
model_id = str(entry["id"])[:300]
|
model_id = str(entry["id"])[:300]
|
||||||
|
|||||||
@@ -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}.")
|
||||||
@@ -13,6 +13,7 @@ from lembas.security import permissions
|
|||||||
from lembas.services import audio as audio_service
|
from lembas.services import audio as audio_service
|
||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
from lembas.services import settings_store
|
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.library import documents as documents_service
|
||||||
from lembas.services.markdown import render_markdown
|
from lembas.services.markdown import render_markdown
|
||||||
from lembas.web.templating import STATIC_DIR, render
|
from lembas.web.templating import STATIC_DIR, render
|
||||||
@@ -201,6 +202,7 @@ async def chat_index(
|
|||||||
**context,
|
**context,
|
||||||
"current_model": preselected,
|
"current_model": preselected,
|
||||||
"starting_temporary": temporary,
|
"starting_temporary": temporary,
|
||||||
|
"suggestions": suggestions_service.visible(db),
|
||||||
**sidebar_context(db, user),
|
**sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from lembas.db.models.library import (
|
|||||||
chat_knowledge_bases,
|
chat_knowledge_bases,
|
||||||
)
|
)
|
||||||
from lembas.db.models.setting import Setting
|
from lembas.db.models.setting import Setting
|
||||||
|
from lembas.db.models.suggestion import Suggestion
|
||||||
from lembas.db.models.user import (
|
from lembas.db.models.user import (
|
||||||
ROLE_ADMIN,
|
ROLE_ADMIN,
|
||||||
ROLE_PENDING,
|
ROLE_PENDING,
|
||||||
@@ -85,6 +86,7 @@ __all__ = [
|
|||||||
"Share",
|
"Share",
|
||||||
"Skill",
|
"Skill",
|
||||||
"SkillRevision",
|
"SkillRevision",
|
||||||
|
"Suggestion",
|
||||||
"User",
|
"User",
|
||||||
"chat_knowledge_bases",
|
"chat_knowledge_bases",
|
||||||
"model_groups",
|
"model_groups",
|
||||||
|
|||||||
@@ -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"<Suggestion {self.name}>"
|
||||||
@@ -18,6 +18,7 @@ from lembas.api import (
|
|||||||
admin_models,
|
admin_models,
|
||||||
admin_prompts,
|
admin_prompts,
|
||||||
admin_search,
|
admin_search,
|
||||||
|
admin_suggestions,
|
||||||
admin_users,
|
admin_users,
|
||||||
audio,
|
audio,
|
||||||
auth,
|
auth,
|
||||||
@@ -65,6 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
from lembas.services.chat import sweep_temporary
|
from lembas.services.chat import sweep_temporary
|
||||||
from lembas.services.files import sweep_orphans
|
from lembas.services.files import sweep_orphans
|
||||||
from lembas.services.library.documents import sweep_unfiled
|
from lembas.services.library.documents import sweep_unfiled
|
||||||
|
from lembas.services.suggestions import seed_defaults as seed_suggestions
|
||||||
|
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
sweep_orphans(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
|
# Temporary chats older than a day. Startup only, like the sweeps
|
||||||
# above it -- see services/chat.py:sweep_temporary.
|
# above it -- see services/chat.py:sweep_temporary.
|
||||||
sweep_temporary(db)
|
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
|
except Exception: # noqa: BLE001 - housekeeping must never block startup
|
||||||
log.exception("orphaned upload sweep failed")
|
log.exception("orphaned upload sweep failed")
|
||||||
|
|
||||||
@@ -115,6 +119,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(admin_audio.router)
|
app.include_router(admin_audio.router)
|
||||||
app.include_router(admin_search.router)
|
app.include_router(admin_search.router)
|
||||||
app.include_router(admin_prompts.router)
|
app.include_router(admin_prompts.router)
|
||||||
|
app.include_router(admin_suggestions.router)
|
||||||
|
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -167,6 +167,43 @@
|
|||||||
.reasoning__summary::-webkit-details-marker { display: none; }
|
.reasoning__summary::-webkit-details-marker { display: none; }
|
||||||
.reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); }
|
.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 ---------------------------------------------------------------
|
/* --- Metrics ---------------------------------------------------------------
|
||||||
What a reply cost, under the bubble. Quiet by default: it is reference, not
|
What a reply cost, under the bubble. Quiet by default: it is reference, not
|
||||||
something to read every time.
|
something to read every time.
|
||||||
|
|||||||
@@ -384,6 +384,21 @@
|
|||||||
return;
|
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
|
/* Show/hide a panel by selector, so templates do not each carry their own
|
||||||
inline toggle script. */
|
inline toggle script. */
|
||||||
var toggle = event.target.closest("[data-toggle]");
|
var toggle = event.target.closest("[data-toggle]");
|
||||||
|
|||||||
@@ -47,6 +47,11 @@
|
|||||||
{{ icon("sparkle", "icon--sm") }}
|
{{ icon("sparkle", "icon--sm") }}
|
||||||
<span class="nav-item__label">Prompts</span>
|
<span class="nav-item__label">Prompts</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-item {{ 'is-active' if section == 'suggestions' }}"
|
||||||
|
href="/admin/suggestions">
|
||||||
|
{{ icon("star", "icon--sm") }}
|
||||||
|
<span class="nav-item__label">Suggestions</span>
|
||||||
|
</a>
|
||||||
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
|
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
|
||||||
{{ icon("user", "icon--sm") }}
|
{{ icon("user", "icon--sm") }}
|
||||||
<span class="nav-item__label">Users</span>
|
<span class="nav-item__label">Users</span>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
<p class="admin-lede">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if saved %}
|
||||||
|
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for suggestion in suggestions %}
|
||||||
|
<form class="card" method="post" action="/admin/suggestions/{{ suggestion.id }}">
|
||||||
|
<div class="card__header">
|
||||||
|
<h2 class="card__title">
|
||||||
|
{{ suggestion.name }}
|
||||||
|
{% if not suggestion.enabled %}<span class="badge">hidden</span>{% endif %}
|
||||||
|
{% if loop.index > max_shown and suggestion.enabled %}
|
||||||
|
<span class="badge badge--warning" title="Only the first {{ max_shown }} are shown">
|
||||||
|
below the cut
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<button class="btn btn--sm btn--danger" type="submit"
|
||||||
|
formaction="/admin/suggestions/{{ suggestion.id }}/delete"
|
||||||
|
data-confirm-button="Delete the suggestion “{{ suggestion.name }}”?"
|
||||||
|
data-confirm-title="Delete suggestion">
|
||||||
|
{{ icon("trash", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid--2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="name-{{ suggestion.id }}">Name</label>
|
||||||
|
<input class="input" id="name-{{ suggestion.id }}" name="name"
|
||||||
|
value="{{ suggestion.name }}" maxlength="120">
|
||||||
|
<p class="field__hint">The heading on the card.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="position-{{ suggestion.id }}">Position</label>
|
||||||
|
<input class="input" id="position-{{ suggestion.id }}" name="position" type="number"
|
||||||
|
min="1" value="{{ suggestion.position + 1 }}">
|
||||||
|
<p class="field__hint">Order on the screen, lowest first.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="description-{{ suggestion.id }}">Description</label>
|
||||||
|
<input class="input" id="description-{{ suggestion.id }}" name="description"
|
||||||
|
value="{{ suggestion.description }}" maxlength="300">
|
||||||
|
<p class="field__hint">One line under the name, saying what it is for.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="prompt-{{ suggestion.id }}">Prompt</label>
|
||||||
|
<textarea class="textarea" id="prompt-{{ suggestion.id }}" name="prompt"
|
||||||
|
rows="4">{{ suggestion.prompt }}</textarea>
|
||||||
|
<p class="field__hint">
|
||||||
|
Put in the composer, not sent. Ending it mid-sentence is usually right:
|
||||||
|
the person still has to say what they are asking about.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="enabled" value="true" {{ 'checked' if suggestion.enabled }}>
|
||||||
|
<span>Show this one</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card__footer">
|
||||||
|
<button class="btn btn--primary btn--sm" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">
|
||||||
|
{{ icon("sparkle", "empty__mark") }}
|
||||||
|
<h2 class="empty__title">No suggestions</h2>
|
||||||
|
<p class="empty__text">The new-chat screen shows its empty state instead.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not at_limit %}
|
||||||
|
<form class="card" method="post" action="/admin/suggestions">
|
||||||
|
<h2 class="card__title">Add a suggestion</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="new-name">Name</label>
|
||||||
|
<input class="input" id="new-name" name="name" maxlength="120"
|
||||||
|
placeholder="Summarise a document" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="new-description">Description</label>
|
||||||
|
<input class="input" id="new-description" name="description" maxlength="300"
|
||||||
|
placeholder="What it is for, in one line.">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="new-prompt">Prompt</label>
|
||||||
|
<textarea class="textarea" id="new-prompt" name="prompt" rows="4"
|
||||||
|
placeholder="What lands in the composer when the card is clicked."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer">
|
||||||
|
<button class="btn btn--primary" type="submit">Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="admin-lede">
|
||||||
|
{{ 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.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -184,6 +184,22 @@
|
|||||||
{{ mark(cls="empty__mark", uid="intro") }}
|
{{ mark(cls="empty__mark", uid="intro") }}
|
||||||
<h2 class="empty__title">What would you ask?</h2>
|
<h2 class="empty__title">What would you ask?</h2>
|
||||||
<p class="empty__text">Speak, friend, and enter.</p>
|
<p class="empty__text">Speak, friend, and enter.</p>
|
||||||
|
|
||||||
|
{# 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 %}
|
||||||
|
<div class="suggestions">
|
||||||
|
{% for suggestion in suggestions %}
|
||||||
|
<button class="suggestion" type="button"
|
||||||
|
data-suggestion="{{ suggestion.prompt }}">
|
||||||
|
<span class="suggestion__name">{{ suggestion.name }}</span>
|
||||||
|
{% if suggestion.description %}
|
||||||
|
<span class="suggestion__note">{{ suggestion.description }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 "<script>alert(1)</script>"')
|
||||||
|
|
||||||
|
page = client.get("/chat").text
|
||||||
|
assert "<script>alert(1)</script>" 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
|
||||||
Reference in New Issue
Block a user