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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_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
|
||||
|
||||
@@ -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: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.
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
{{ icon("sparkle", "icon--sm") }}
|
||||
<span class="nav-item__label">Prompts</span>
|
||||
</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">
|
||||
{{ icon("user", "icon--sm") }}
|
||||
<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") }}
|
||||
<h2 class="empty__title">What would you ask?</h2>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user