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
|
||||
# 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]
|
||||
|
||||
@@ -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 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),
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user