Split the model admin into a list and a page per model
/admin/models rendered a full edit form for every model. With eight that
was merely long; with a hundred it was unusable, which is the report.
The list is now compact rows only -- avatar, name, badges, position,
reorder buttons, Edit link -- with search across id and display name,
filter tabs (All / Enabled / Disabled / Pinned / Restricted, each with a
count), a connection filter, and pagination at 40. Filters are links, so
a filtered view is a real URL you can keep. Editing moved to
/admin/models/{id}/edit, one model per page, with Previous/Next links so
a freshly imported connection can be tidied without returning to the
list each time.
Measured with 128 models: the list is 73 KB showing 40 rows over 4
pages, and a detail page is 17 KB. The old page would have rendered all
128 forms into one response.
Reordering needed rethinking at that size too. Up/down is fine for
nudging a model one place but hopeless for moving it sixty, so the
detail page has a position field you type into; the value is clamped and
a non-numeric one is ignored rather than throwing. The move buttons take
a `back` field so they return to whatever filtered, paginated view they
were pressed on instead of dumping you at page 1.
Also adds a select-all checkbox for the bulk bar, scoped to a container
selector rather than the page so a future list can carry more than one.
212 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,8 +45,9 @@ runtime. Clone it, `pip install -e .`, run it.
|
|||||||
inside it
|
inside it
|
||||||
- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
|
- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
|
||||||
llama-swap, Ollama or OpenRouter; models are discovered and cached
|
llama-swap, Ollama or OpenRouter; models are discovered and cached
|
||||||
- **Model settings** — ordering, pinned models, an instance default and a
|
- **Model settings** — searchable, filterable list with a page per model:
|
||||||
per-user default, custom names and descriptions, uploaded model images
|
ordering, pinned models, an instance default and a per-user default, custom
|
||||||
|
names, descriptions and images. Scales to hundreds of models
|
||||||
- **Users, groups & permissions** — per-group grants that union rather than
|
- **Users, groups & permissions** — per-group grants that union rather than
|
||||||
override, and model access restricted to chosen groups
|
override, and model access restricted to chosen groups
|
||||||
- **Accounts** — first account becomes the administrator, argon2 password
|
- **Accounts** — first account becomes the administrator, argon2 password
|
||||||
|
|||||||
+130
-14
@@ -47,20 +47,104 @@ def _renumber(db: DBSession) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
# --- The admin page ----------------------------------------------------------
|
# --- Listing -----------------------------------------------------------------
|
||||||
|
PAGE_SIZE = 40
|
||||||
|
|
||||||
|
# Filters offered as tabs above the list. Each is a predicate over a Model.
|
||||||
|
FILTERS: dict[str, tuple[str, object]] = {
|
||||||
|
"all": ("All", lambda m: True),
|
||||||
|
"enabled": ("Enabled", lambda m: m.enabled),
|
||||||
|
"disabled": ("Disabled", lambda m: not m.enabled),
|
||||||
|
"pinned": ("Pinned", lambda m: m.pinned),
|
||||||
|
"restricted": ("Restricted", lambda m: not m.public),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/models")
|
@router.get("/admin/models")
|
||||||
async def models_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
async def models_page(
|
||||||
models = _ordered(db)
|
request: Request,
|
||||||
|
db: Db,
|
||||||
|
user: AdminUser,
|
||||||
|
saved: str = "",
|
||||||
|
q: str = "",
|
||||||
|
filter: str = "all",
|
||||||
|
connection: str = "",
|
||||||
|
page: int = 1,
|
||||||
|
):
|
||||||
|
"""The model list.
|
||||||
|
|
||||||
|
Compact rows only -- editing happens on a page of its own. A connection can
|
||||||
|
advertise a hundred models, and a list that renders a full form for each of
|
||||||
|
them is unusable at that size.
|
||||||
|
"""
|
||||||
|
everything = _ordered(db)
|
||||||
|
|
||||||
|
predicate = FILTERS.get(filter, FILTERS["all"])[1]
|
||||||
|
needle = q.strip().lower()
|
||||||
|
|
||||||
|
matching = [
|
||||||
|
model
|
||||||
|
for model in everything
|
||||||
|
if predicate(model)
|
||||||
|
and (not connection or model.connection_id == connection)
|
||||||
|
and (
|
||||||
|
not needle
|
||||||
|
or needle in model.model_id.lower()
|
||||||
|
or needle in (model.display_name or "").lower()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
pages = max(1, -(-len(matching) // PAGE_SIZE))
|
||||||
|
page = max(1, min(page, pages))
|
||||||
|
start = (page - 1) * PAGE_SIZE
|
||||||
|
visible = matching[start : start + PAGE_SIZE]
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"admin/models.html",
|
"admin/models.html",
|
||||||
{
|
{
|
||||||
"models": models,
|
"models": visible,
|
||||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
"total": len(everything),
|
||||||
|
"matched": len(matching),
|
||||||
|
"page": page,
|
||||||
|
"pages": pages,
|
||||||
|
"page_start": start,
|
||||||
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
|
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
|
||||||
"default_model": settings_store.get(db, "default_model") or "",
|
"default_model": settings_store.get(db, "default_model") or "",
|
||||||
"instance_prompt": settings_store.get(db, "system_prompt") or "",
|
"counts": {
|
||||||
|
key: sum(1 for m in everything if test(m)) for key, (_, test) in FILTERS.items()
|
||||||
|
},
|
||||||
|
"filters": {key: label for key, (label, _) in FILTERS.items()},
|
||||||
|
"active_filter": filter if filter in FILTERS else "all",
|
||||||
|
"q": q,
|
||||||
|
"connection_id": connection,
|
||||||
|
"saved": saved,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/models/{model_id}/edit")
|
||||||
|
async def model_detail(
|
||||||
|
request: Request, db: Db, user: AdminUser, model_id: str, saved: str = ""
|
||||||
|
):
|
||||||
|
"""Everything about one model, on its own page."""
|
||||||
|
model = _model(db, model_id)
|
||||||
|
ordered = _ordered(db)
|
||||||
|
index = next((i for i, m in enumerate(ordered) if m.id == model.id), 0)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"admin/model_detail.html",
|
||||||
|
{
|
||||||
|
"model": model,
|
||||||
|
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||||
"capabilities": CAPABILITIES,
|
"capabilities": CAPABILITIES,
|
||||||
|
"default_model": settings_store.get(db, "default_model") or "",
|
||||||
|
"instance_prompt": settings_store.get(db, "system_prompt") or "",
|
||||||
|
"position_of": index + 1,
|
||||||
|
"total": len(ordered),
|
||||||
|
"previous": ordered[index - 1] if index > 0 else None,
|
||||||
|
"next": ordered[index + 1] if index + 1 < len(ordered) else None,
|
||||||
"saved": saved,
|
"saved": saved,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -107,6 +191,7 @@ async def update_model(
|
|||||||
enabled: bool = Form(False),
|
enabled: bool = Form(False),
|
||||||
pinned: bool = Form(False),
|
pinned: bool = Form(False),
|
||||||
public: bool = Form(False),
|
public: bool = Form(False),
|
||||||
|
position: str = Form(""),
|
||||||
group_ids: list[str] = Form(default=[]),
|
group_ids: list[str] = Form(default=[]),
|
||||||
capability: list[str] = Form(default=[]),
|
capability: list[str] = Form(default=[]),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -130,13 +215,34 @@ async def update_model(
|
|||||||
model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Typing a position is the only workable way to reorder a long list; the
|
||||||
|
# up/down buttons are for nudging a model one place.
|
||||||
|
if position.strip():
|
||||||
|
try:
|
||||||
|
wanted = max(1, int(position)) - 1
|
||||||
|
except ValueError:
|
||||||
|
wanted = None
|
||||||
|
if wanted is not None:
|
||||||
|
ordered = [m for m in _ordered(db) if m.id != model.id]
|
||||||
|
ordered.insert(min(wanted, len(ordered)), model)
|
||||||
|
for index, item in enumerate(ordered):
|
||||||
|
item.position = index
|
||||||
|
db.commit()
|
||||||
|
|
||||||
log.info("model %s updated by %s", model.model_id, user.email)
|
log.info("model %s updated by %s", model.model_id, user.email)
|
||||||
return RedirectResponse("/admin/models?saved=Model+saved.", status_code=303)
|
return RedirectResponse(
|
||||||
|
f"/admin/models/{model.id}/edit?saved=Saved.", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/models/{model_id}/move")
|
@router.post("/admin/models/{model_id}/move")
|
||||||
async def move_model(
|
async def move_model(
|
||||||
db: Db, user: AdminUser, model_id: str, direction: str = Form(...)
|
db: Db,
|
||||||
|
user: AdminUser,
|
||||||
|
model_id: str,
|
||||||
|
direction: str = Form(...),
|
||||||
|
back: str = Form(""),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Swap a model with its neighbour."""
|
"""Swap a model with its neighbour."""
|
||||||
model = _model(db, model_id)
|
model = _model(db, model_id)
|
||||||
@@ -153,17 +259,21 @@ async def move_model(
|
|||||||
item.position = position
|
item.position = position
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return RedirectResponse("/admin/models", status_code=303)
|
# Back to whichever filtered, paginated view the button was pressed on.
|
||||||
|
return RedirectResponse(back or "/admin/models", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/models/{model_id}/default")
|
@router.post("/admin/models/{model_id}/default")
|
||||||
async def set_default_model(db: Db, user: AdminUser, model_id: str) -> Response:
|
async def set_default_model(
|
||||||
|
db: Db, user: AdminUser, model_id: str, back: str = Form("")
|
||||||
|
) -> Response:
|
||||||
"""Make a model the instance default for new chats."""
|
"""Make a model the instance default for new chats."""
|
||||||
model = _model(db, model_id)
|
model = _model(db, model_id)
|
||||||
settings_store.update(db, {"default_model": model.model_id})
|
settings_store.update(db, {"default_model": model.model_id})
|
||||||
log.info("default model set to %s by %s", model.model_id, user.email)
|
log.info("default model set to %s by %s", model.model_id, user.email)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/admin/models?saved={model.label}+is+now+the+default.", status_code=303
|
back or f"/admin/models/{model.id}/edit?saved=Now+the+default+model.",
|
||||||
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -177,7 +287,9 @@ async def upload_model_image(
|
|||||||
try:
|
try:
|
||||||
filename = uploads.save_model_image(payload, image.content_type or "")
|
filename = uploads.save_model_image(payload, image.content_type or "")
|
||||||
except uploads.UploadError as exc:
|
except uploads.UploadError as exc:
|
||||||
return RedirectResponse(f"/admin/models?saved={exc}", status_code=303)
|
return RedirectResponse(
|
||||||
|
f"/admin/models/{model.id}/edit?saved={exc}", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
# Remove the old file rather than orphaning it in the uploads directory.
|
# Remove the old file rather than orphaning it in the uploads directory.
|
||||||
if model.image_path:
|
if model.image_path:
|
||||||
@@ -185,7 +297,9 @@ async def upload_model_image(
|
|||||||
|
|
||||||
model.image_path = filename
|
model.image_path = filename
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse("/admin/models?saved=Image+updated.", status_code=303)
|
return RedirectResponse(
|
||||||
|
f"/admin/models/{model.id}/edit?saved=Image+updated.", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/models/{model_id}/image/delete")
|
@router.post("/admin/models/{model_id}/image/delete")
|
||||||
@@ -195,7 +309,9 @@ async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response
|
|||||||
uploads.delete_model_image(model.image_path)
|
uploads.delete_model_image(model.image_path)
|
||||||
model.image_path = ""
|
model.image_path = ""
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse("/admin/models?saved=Image+removed.", status_code=303)
|
return RedirectResponse(
|
||||||
|
f"/admin/models/{model.id}/edit?saved=Image+removed.", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Serving model images ----------------------------------------------------
|
# --- Serving model images ----------------------------------------------------
|
||||||
|
|||||||
@@ -162,23 +162,70 @@
|
|||||||
.status-dot.is-bad { background: var(--danger); }
|
.status-dot.is-bad { background: var(--danger); }
|
||||||
.status-dot.is-off { background: var(--ink-faint); }
|
.status-dot.is-off { background: var(--ink-faint); }
|
||||||
|
|
||||||
/* --- Model list ------------------------------------------------------------ */
|
/* --- Filter bar ------------------------------------------------------------ */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.filter-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
height: var(--control-h);
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.filter-tab:hover { color: var(--ink); }
|
||||||
|
.filter-tab.is-active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||||
|
.filter-tab__count {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
background: var(--surface-active);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
min-width: 1.4rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.filter-tab.is-active .filter-tab__count { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
|
||||||
|
.filter-form { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.filter-form .input { width: auto; flex: 1; min-width: 12rem; }
|
||||||
|
.filter-form .select { width: auto; min-width: 10rem; }
|
||||||
|
|
||||||
|
/* --- Model list ------------------------------------------------------------
|
||||||
|
Compact rows only. Editing is a page of its own -- a connection can advertise
|
||||||
|
a hundred models, and a list that renders a form for each is unusable.
|
||||||
|
*/
|
||||||
.bulk-bar {
|
.bulk-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
background: var(--surface);
|
border-bottom: 0;
|
||||||
margin-bottom: var(--sp-4);
|
background: var(--bg-sunken);
|
||||||
}
|
}
|
||||||
.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); margin-right: var(--sp-1); }
|
.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); }
|
||||||
|
|
||||||
.model-rows {
|
.model-rows {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
@@ -186,21 +233,36 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.model-row:last-child { border-bottom: 0; }
|
.model-row:last-child { border-bottom: 0; }
|
||||||
.model-row.is-off { opacity: 0.5; }
|
.model-row:hover { background: var(--surface-hover); }
|
||||||
|
.model-row.is-off { opacity: 0.55; }
|
||||||
.model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
|
.model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
|
||||||
.model-row__avatar { flex: none; width: 2rem; height: 2rem; }
|
.model-row__pos {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
min-width: 1.75rem;
|
||||||
|
text-align: right;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.model-row__avatar { flex: none; width: 1.75rem; height: 1.75rem; }
|
||||||
.model-row__main { flex: 1; min-width: 0; }
|
.model-row__main { flex: 1; min-width: 0; }
|
||||||
.model-row__title {
|
.model-row__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 0.1rem;
|
|
||||||
}
|
}
|
||||||
|
.model-row__name {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.model-row__name:hover { color: var(--accent); text-decoration: underline; }
|
||||||
.model-row__id {
|
.model-row__id {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -212,6 +274,34 @@
|
|||||||
}
|
}
|
||||||
.model-row__actions { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
|
.model-row__actions { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
|
||||||
|
|
||||||
|
.list-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Model detail ---------------------------------------------------------- */
|
||||||
|
.crumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.crumbs > a:first-child {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.crumbs > a:first-child:hover { color: var(--ink); }
|
||||||
|
.crumbs__back { transform: rotate(180deg); }
|
||||||
|
.model-detail__avatar { width: 3rem; height: 3rem; flex: none; }
|
||||||
|
|
||||||
.model-list { list-style: none; margin: 0; padding: 0; }
|
.model-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.model-list__item {
|
.model-list__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -232,6 +232,18 @@
|
|||||||
if (event.target.matches("[data-autosize]")) autosize(event.target);
|
if (event.target.matches("[data-autosize]")) autosize(event.target);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* A "select all" box driving every checkbox inside a container. Scoped to a
|
||||||
|
selector rather than the whole page, so a list can carry more than one. */
|
||||||
|
document.addEventListener("change", function (event) {
|
||||||
|
var master = event.target.closest("[data-select-all]");
|
||||||
|
if (!master) return;
|
||||||
|
var scope = document.querySelector(master.dataset.selectAll);
|
||||||
|
if (!scope) return;
|
||||||
|
scope.querySelectorAll('input[type="checkbox"]').forEach(function (box) {
|
||||||
|
box.checked = master.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/* Enter sends, Shift+Enter inserts a newline -- the convention every chat
|
/* Enter sends, Shift+Enter inserts a newline -- the convention every chat
|
||||||
application uses. Left alone on touch devices, where there is no easy
|
application uses. Left alone on touch devices, where there is no easy
|
||||||
Shift and Enter should mean "new line". */
|
Shift and Enter should mean "new line". */
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
{% extends "admin/_layout.html" %}
|
||||||
|
{% from "_macros.html" import icon, model_avatar %}
|
||||||
|
{% set section = "models" %}
|
||||||
|
|
||||||
|
{% block title %}{{ model.label }} - Models - LLeMbas{% endblock %}
|
||||||
|
{% block heading %}{{ model.label }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<nav class="crumbs">
|
||||||
|
<a href="/admin/models">{{ icon("chevron-right", "icon--sm crumbs__back") }} All models</a>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
{# Walking the list without going back to it, which is what you want when
|
||||||
|
tidying up a freshly imported connection. #}
|
||||||
|
{% if previous %}
|
||||||
|
<a class="btn btn--sm" href="/admin/models/{{ previous.id }}/edit"
|
||||||
|
title="{{ previous.label }}">{{ icon("arrow-up", "icon--sm") }} Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs faint">{{ position_of }} of {{ total }}</span>
|
||||||
|
{% if next %}
|
||||||
|
<a class="btn btn--sm" href="/admin/models/{{ next.id }}/edit"
|
||||||
|
title="{{ next.label }}">Next {{ icon("arrow-down", "icon--sm") }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if saved %}
|
||||||
|
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="row" style="gap: var(--sp-3); min-width: 0">
|
||||||
|
{{ model_avatar(model, cls="model-detail__avatar") }}
|
||||||
|
<div style="min-width: 0">
|
||||||
|
<strong>{{ model.label }}</strong>
|
||||||
|
<div><code class="text-xs faint">{{ model.model_id }}</code></div>
|
||||||
|
<div class="text-xs faint">via {{ model.connection.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
{% if model.model_id == default_model %}
|
||||||
|
<span class="badge badge--gold">{{ icon("star", "icon--sm") }} default model</span>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/admin/models/{{ model.id }}/default">
|
||||||
|
<button class="btn btn--sm" type="submit">
|
||||||
|
{{ icon("star", "icon--sm") }} Make default
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<form method="post" action="/admin/models/{{ model.id }}/image"
|
||||||
|
enctype="multipart/form-data" class="btn-row">
|
||||||
|
<input class="input input--file" type="file" name="image"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif" required
|
||||||
|
aria-label="Model image">
|
||||||
|
<button class="btn btn--sm" type="submit">{{ icon("image", "icon--sm") }} Upload image</button>
|
||||||
|
</form>
|
||||||
|
{% if model.image_path %}
|
||||||
|
<form method="post" action="/admin/models/{{ model.id }}/image/delete">
|
||||||
|
<button class="btn btn--sm btn--danger" type="submit">Remove image</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="field__hint">
|
||||||
|
PNG, JPEG, WEBP or GIF, under 2 MB. Without one, the model gets a generated
|
||||||
|
badge whose colour is derived from its id.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/models/{{ model.id }}">
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">Presentation</h2>
|
||||||
|
|
||||||
|
<div class="grid grid--2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="display-name">Display name</label>
|
||||||
|
<input class="input" id="display-name" name="display_name"
|
||||||
|
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
|
||||||
|
<p class="field__hint">Shown instead of the raw id. Empty uses the id.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="position">Position</label>
|
||||||
|
<input class="input" id="position" name="position" type="number" min="1"
|
||||||
|
max="{{ total }}" value="{{ position_of }}">
|
||||||
|
<p class="field__hint">
|
||||||
|
Place in the picker, 1–{{ total }}. Typing a number is the workable way
|
||||||
|
to move a model a long distance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="description">Description</label>
|
||||||
|
<textarea class="textarea" id="description" name="description" rows="2"
|
||||||
|
placeholder="What is this model good at?">{{ model.description }}</textarea>
|
||||||
|
<p class="field__hint">Shown in the chat settings panel and your users' settings.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">System prompt</h2>
|
||||||
|
<p class="card__lede">
|
||||||
|
Used by chats on this model that have no prompt of their own. A chat's own
|
||||||
|
prompt overrides this; this overrides the instance prompt.
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label visually-hidden" for="system-prompt">System prompt</label>
|
||||||
|
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
|
||||||
|
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
|
||||||
|
>{{ model.system_prompt }}</textarea>
|
||||||
|
<p class="field__hint">
|
||||||
|
{% if instance_prompt %}
|
||||||
|
Leave empty to fall back to the instance prompt shown above.
|
||||||
|
{% else %}
|
||||||
|
Leave empty to send no system prompt.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">Capabilities</h2>
|
||||||
|
<p class="card__lede">
|
||||||
|
Endpoints rarely advertise these reliably, so they are your call.
|
||||||
|
<strong>reasoning</strong> shows the thinking block,
|
||||||
|
<strong>vision</strong> lets images be sent to this model, and
|
||||||
|
<strong>tools</strong> is reserved for a feature not built yet.
|
||||||
|
</p>
|
||||||
|
<div class="checkbox-row">
|
||||||
|
{% for name in capabilities %}
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="capability" value="{{ name }}"
|
||||||
|
{{ 'checked' if (model.capabilities_json or {}).get(name) }}>
|
||||||
|
<span>{{ name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">Availability</h2>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
|
||||||
|
<span>Enabled — offered in chats</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
|
||||||
|
<span>Pinned — shortcut in the chat sidebar</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
|
||||||
|
<span>Available to everyone</span>
|
||||||
|
</label>
|
||||||
|
<p class="field__hint">
|
||||||
|
Uncheck to restrict this model to chosen groups. Administrators always
|
||||||
|
have access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field__label">Groups with access</span>
|
||||||
|
{% if groups %}
|
||||||
|
<div class="checkbox-row">
|
||||||
|
{% for group in groups %}
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="group_ids" value="{{ group.id }}"
|
||||||
|
{{ 'checked' if group in model.groups }}>
|
||||||
|
<span>{{ group.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="field__hint">Ignored while the model is available to everyone.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="field__hint">
|
||||||
|
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||||
|
<a class="btn btn--ghost" href="/admin/models">Back to all models</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not models %}
|
{% if not total %}
|
||||||
<div class="empty" style="padding: var(--sp-10) 0">
|
<div class="empty" style="padding: var(--sp-10) 0">
|
||||||
{{ icon("server", "empty__mark") }}
|
{{ icon("server", "empty__mark") }}
|
||||||
<p class="empty__text">
|
<p class="empty__text">
|
||||||
@@ -27,8 +27,50 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
|
{# Filters are links, so a filtered view is a real URL you can keep or share. #}
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="filter-tabs">
|
||||||
|
{% for key, label in filters.items() %}
|
||||||
|
<a class="filter-tab {{ 'is-active' if key == active_filter }}"
|
||||||
|
href="/admin/models?filter={{ key }}{% if q %}&q={{ q|urlencode }}{% endif %}{% if connection_id %}&connection={{ connection_id }}{% endif %}">
|
||||||
|
{{ label }} <span class="filter-tab__count">{{ counts[key] }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="filter-form" method="get" action="/admin/models">
|
||||||
|
<input type="hidden" name="filter" value="{{ active_filter }}">
|
||||||
|
<input class="input" type="search" name="q" value="{{ q }}"
|
||||||
|
placeholder="Search models…" aria-label="Search models">
|
||||||
|
<select class="select" name="connection" aria-label="Connection">
|
||||||
|
<option value="">All connections</option>
|
||||||
|
{% for conn in connections %}
|
||||||
|
<option value="{{ conn.id }}" {{ 'selected' if conn.id == connection_id }}>
|
||||||
|
{{ conn.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
|
||||||
|
{% if q or connection_id or active_filter != "all" %}
|
||||||
|
<a class="btn btn--ghost" href="/admin/models">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not models %}
|
||||||
|
<div class="empty" style="padding: var(--sp-8) 0">
|
||||||
|
<p class="empty__text">Nothing matches that filter.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
<form method="post" action="/admin/models/bulk">
|
<form method="post" action="/admin/models/bulk">
|
||||||
<div class="bulk-bar">
|
<div class="bulk-bar">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" data-select-all="#model-rows"
|
||||||
|
aria-label="Select every model on this page">
|
||||||
|
<span class="bulk-bar__label">Select all</span>
|
||||||
|
</label>
|
||||||
|
<span class="spacer"></span>
|
||||||
<span class="bulk-bar__label">With selected:</span>
|
<span class="bulk-bar__label">With selected:</span>
|
||||||
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
|
<button class="btn btn--sm" name="action" value="enable" type="submit">Enable</button>
|
||||||
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
|
<button class="btn btn--sm" name="action" value="disable" type="submit">Disable</button>
|
||||||
@@ -36,174 +78,74 @@
|
|||||||
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
|
<button class="btn btn--sm" name="action" value="private" type="submit">Restrict</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="model-rows">
|
<div class="model-rows" id="model-rows">
|
||||||
{% for model in models %}
|
{% for model in models %}
|
||||||
<div class="model-row {{ 'is-off' if not model.enabled }}">
|
<div class="model-row {{ 'is-off' if not model.enabled }}">
|
||||||
<input class="model-row__check" type="checkbox" name="model_ids" value="{{ model.id }}"
|
<input class="model-row__check" type="checkbox" name="model_ids" value="{{ model.id }}"
|
||||||
aria-label="Select {{ model.label }}">
|
aria-label="Select {{ model.label }}">
|
||||||
|
|
||||||
|
<span class="model-row__pos">{{ page_start + loop.index }}</span>
|
||||||
|
|
||||||
{{ model_avatar(model, cls="model-row__avatar") }}
|
{{ model_avatar(model, cls="model-row__avatar") }}
|
||||||
|
|
||||||
<div class="model-row__main">
|
<div class="model-row__main">
|
||||||
<div class="model-row__title">
|
<div class="model-row__title">
|
||||||
<strong>{{ model.label }}</strong>
|
<a class="model-row__name" href="/admin/models/{{ model.id }}/edit">{{ model.label }}</a>
|
||||||
{% if model.model_id == default_model %}
|
{% if model.model_id == default_model %}
|
||||||
<span class="badge badge--gold">{{ icon("star", "icon--sm") }} default</span>
|
<span class="badge badge--gold">default</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
|
{% if model.pinned %}<span class="badge">pinned</span>{% endif %}
|
||||||
{% if not model.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
|
{% if not model.enabled %}<span class="badge badge--danger">disabled</span>{% endif %}
|
||||||
{% if not model.public %}
|
{% if not model.public %}<span class="badge">restricted</span>{% endif %}
|
||||||
<span class="badge">{{ model.groups|length }} group{{ '' if model.groups|length == 1 else 's' }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% for name, on in (model.capabilities_json or {}).items() %}
|
{% for name, on in (model.capabilities_json or {}).items() %}
|
||||||
{% if on %}<span class="badge badge--gold">{{ name }}</span>{% endif %}
|
{% if on %}<span class="badge badge--gold">{{ name }}</span>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<code class="model-row__id">{{ model.model_id }}</code>
|
<code class="model-row__id">{{ model.model_id }} · {{ model.connection.name }}</code>
|
||||||
<span class="text-xs faint">via {{ model.connection.name }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="model-row__actions">
|
<div class="model-row__actions">
|
||||||
|
{# formaction lets these post elsewhere without nesting a second form. #}
|
||||||
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move up"
|
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move up"
|
||||||
formaction="/admin/models/{{ model.id }}/move" formmethod="post"
|
formaction="/admin/models/{{ model.id }}/move" name="direction" value="up"
|
||||||
name="direction" value="up" {{ 'disabled' if loop.first }}>
|
{{ 'disabled' if page == 1 and loop.first }}>
|
||||||
{{ icon("arrow-up", "icon--sm") }}
|
{{ icon("arrow-up", "icon--sm") }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move down"
|
<button class="btn btn--icon btn--sm" type="submit" aria-label="Move down"
|
||||||
formaction="/admin/models/{{ model.id }}/move" formmethod="post"
|
formaction="/admin/models/{{ model.id }}/move" name="direction" value="down"
|
||||||
name="direction" value="down" {{ 'disabled' if loop.last }}>
|
{{ 'disabled' if page == pages and loop.last }}>
|
||||||
{{ icon("arrow-down", "icon--sm") }}
|
{{ icon("arrow-down", "icon--sm") }}
|
||||||
</button>
|
</button>
|
||||||
<a class="btn btn--sm" href="#model-{{ model.id }}">Edit</a>
|
<a class="btn btn--sm" href="/admin/models/{{ model.id }}/edit">Edit</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2 class="admin-section-title">Model settings</h2>
|
<div class="list-footer">
|
||||||
|
<span class="text-xs faint">
|
||||||
{% for model in models %}
|
Showing {{ page_start + 1 }}–{{ page_start + models|length }} of {{ matched }}
|
||||||
<section class="card" id="model-{{ model.id }}">
|
{%- if matched != total %} (filtered from {{ total }}){% endif %}
|
||||||
<div class="card__header">
|
</span>
|
||||||
<div class="row" style="gap: var(--sp-3); min-width: 0">
|
|
||||||
{{ model_avatar(model, cls="model-row__avatar") }}
|
|
||||||
<div style="min-width: 0">
|
|
||||||
<strong class="truncate">{{ model.label }}</strong>
|
|
||||||
<div><code class="text-xs faint">{{ model.model_id }}</code></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if model.model_id != default_model %}
|
|
||||||
<form method="post" action="/admin/models/{{ model.id }}/default">
|
|
||||||
<button class="btn btn--sm" type="submit">{{ icon("star", "icon--sm") }} Make default</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/models/{{ model.id }}">
|
|
||||||
<div class="field">
|
|
||||||
<label class="field__label" for="dn-{{ model.id }}">Display name</label>
|
|
||||||
<input class="input" id="dn-{{ model.id }}" name="display_name"
|
|
||||||
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
|
|
||||||
<p class="field__hint">Shown instead of the raw model id. Leave empty to use the id.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="field__label" for="desc-{{ model.id }}">Description</label>
|
|
||||||
<textarea class="textarea" id="desc-{{ model.id }}" name="description" rows="2"
|
|
||||||
placeholder="What is this model good at?">{{ model.description }}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="field__label" for="sp-{{ model.id }}">System prompt</label>
|
|
||||||
<textarea class="textarea" id="sp-{{ model.id }}" name="system_prompt" rows="3"
|
|
||||||
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
|
|
||||||
>{{ model.system_prompt }}</textarea>
|
|
||||||
<p class="field__hint">
|
|
||||||
Used by chats on this model that have no prompt of their own.
|
|
||||||
{% if instance_prompt %}Leave empty to fall back to the instance prompt.
|
|
||||||
{% else %}Leave empty to send no system prompt.{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="field__label">Capabilities</span>
|
|
||||||
<div class="checkbox-row">
|
|
||||||
{% for name in capabilities %}
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="capability" value="{{ name }}"
|
|
||||||
{{ 'checked' if (model.capabilities_json or {}).get(name) }}>
|
|
||||||
<span>{{ name }}</span>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<p class="field__hint">
|
|
||||||
Endpoints rarely advertise these reliably, so they are your call.
|
|
||||||
<strong>reasoning</strong> shows the thinking block;
|
|
||||||
<strong>vision</strong> and <strong>tools</strong> are used by features
|
|
||||||
not built yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<div class="checkbox-row">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
|
|
||||||
<span>Enabled</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
|
|
||||||
<span>Pinned — shortcut in the chat sidebar</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
|
|
||||||
<span>Available to everyone</span>
|
|
||||||
</label>
|
|
||||||
<p class="field__hint">
|
|
||||||
Uncheck to restrict this model to specific groups. Administrators always
|
|
||||||
have access.
|
|
||||||
</p>
|
|
||||||
{% if groups %}
|
|
||||||
<div class="checkbox-row" style="margin-top: var(--sp-3)">
|
|
||||||
{% for group in groups %}
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="group_ids" value="{{ group.id }}"
|
|
||||||
{{ 'checked' if group in model.groups }}>
|
|
||||||
<span>{{ group.name }}</span>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="field__hint">
|
|
||||||
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{% if pages > 1 %}
|
||||||
|
{% set base = "/admin/models?filter=" ~ active_filter ~ ("&q=" ~ q|urlencode if q else "") ~ ("&connection=" ~ connection_id if connection_id else "") %}
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn--primary" type="submit">Save {{ model.label }}</button>
|
{% if page > 1 %}
|
||||||
</div>
|
<a class="btn btn--sm" href="{{ base }}&page={{ page - 1 }}">Previous</a>
|
||||||
</form>
|
{% else %}
|
||||||
|
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Previous</span>
|
||||||
<div class="card__footer">
|
{% endif %}
|
||||||
<form method="post" action="/admin/models/{{ model.id }}/image"
|
<span class="text-xs faint">Page {{ page }} of {{ pages }}</span>
|
||||||
enctype="multipart/form-data" class="btn-row">
|
{% if page < pages %}
|
||||||
<input class="input input--file" type="file" name="image"
|
<a class="btn btn--sm" href="{{ base }}&page={{ page + 1 }}">Next</a>
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif" required
|
{% else %}
|
||||||
aria-label="Model image">
|
<span class="btn btn--sm" aria-disabled="true" style="opacity: .45">Next</span>
|
||||||
<button class="btn btn--sm" type="submit">{{ icon("image", "icon--sm") }} Upload</button>
|
|
||||||
</form>
|
|
||||||
{% if model.image_path %}
|
|
||||||
<form method="post" action="/admin/models/{{ model.id }}/image/delete">
|
|
||||||
<button class="btn btn--sm btn--danger" type="submit">Remove image</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{% endif %}
|
||||||
{% endfor %}
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -477,3 +477,186 @@ def test_bulk_with_nothing_selected_is_harmless(client: TestClient, db, register
|
|||||||
assert client.post(
|
assert client.post(
|
||||||
"/admin/models/bulk", data={"action": "disable"}, follow_redirects=False
|
"/admin/models/bulk", data={"action": "disable"}, follow_redirects=False
|
||||||
).status_code == 303
|
).status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
# --- The model admin list ----------------------------------------------------
|
||||||
|
def _many_models(db, count: int) -> None:
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Model(connection_id=connection.id, model_id=f"model-{i:03d}", position=i)
|
||||||
|
for i in range(count)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_list_paginates_rather_than_rendering_everything(
|
||||||
|
client: TestClient, db, registered
|
||||||
|
):
|
||||||
|
"""A hundred models must not become a hundred forms on one page."""
|
||||||
|
from lembas.api.admin_models import PAGE_SIZE
|
||||||
|
|
||||||
|
_many_models(db, PAGE_SIZE + 15)
|
||||||
|
page = client.get("/admin/models").text
|
||||||
|
|
||||||
|
assert page.count('name="model_ids"') == PAGE_SIZE
|
||||||
|
assert f"of {PAGE_SIZE + 15}" in page
|
||||||
|
assert "Page 1 of 2" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_second_page_shows_the_remainder(client: TestClient, db, registered):
|
||||||
|
from lembas.api.admin_models import PAGE_SIZE
|
||||||
|
|
||||||
|
_many_models(db, PAGE_SIZE + 15)
|
||||||
|
page = client.get("/admin/models?page=2").text
|
||||||
|
assert page.count('name="model_ids"') == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_out_of_range_page_is_clamped(client: TestClient, db, registered):
|
||||||
|
_many_models(db, 5)
|
||||||
|
assert "Page 1 of 1" not in client.get("/admin/models?page=99").text
|
||||||
|
assert client.get("/admin/models?page=99").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_list_does_not_render_edit_forms(client: TestClient, db, registered):
|
||||||
|
"""The whole point of the split: rows link to a page, they are not forms."""
|
||||||
|
_many_models(db, 3)
|
||||||
|
page = client.get("/admin/models").text
|
||||||
|
assert 'name="system_prompt"' not in page
|
||||||
|
assert 'name="display_name"' not in page
|
||||||
|
assert page.count("/edit") >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_narrows_the_list(client: TestClient, db, registered):
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Model(connection_id=connection.id, model_id="llama-large", position=0),
|
||||||
|
Model(connection_id=connection.id, model_id="qwen-small", position=1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
page = client.get("/admin/models?q=qwen").text
|
||||||
|
assert "qwen-small" in page
|
||||||
|
assert "llama-large" not in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_matches_the_display_name_too(client: TestClient, db, registered):
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add(
|
||||||
|
Model(connection_id=connection.id, model_id="abc-123", display_name="Friendly Name")
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
assert "abc-123" in client.get("/admin/models?q=friendly").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_tabs_narrow_the_list(client: TestClient, db, registered):
|
||||||
|
connection = _connection(db)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Model(connection_id=connection.id, model_id="on-model", position=0),
|
||||||
|
Model(connection_id=connection.id, model_id="off-model", position=1, enabled=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
disabled = client.get("/admin/models?filter=disabled").text
|
||||||
|
assert "off-model" in disabled
|
||||||
|
assert ">on-model" not in disabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtering_by_connection(client: TestClient, db, registered):
|
||||||
|
first = _connection(db)
|
||||||
|
second = _connection(db)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Model(connection_id=first.id, model_id="from-first", position=0),
|
||||||
|
Model(connection_id=second.id, model_id="from-second", position=1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
page = client.get(f"/admin/models?connection={second.id}").text
|
||||||
|
assert "from-second" in page
|
||||||
|
assert "from-first" not in page
|
||||||
|
|
||||||
|
|
||||||
|
# --- The model detail page ---------------------------------------------------
|
||||||
|
def test_the_detail_page_carries_the_full_form(client: TestClient, db, registered):
|
||||||
|
_many_models(db, 3)
|
||||||
|
db.add(Group(name="Insiders"))
|
||||||
|
db.commit()
|
||||||
|
model = db.scalar(select(Model).where(Model.model_id == "model-001"))
|
||||||
|
|
||||||
|
page = client.get(f"/admin/models/{model.id}/edit").text
|
||||||
|
for field in ('name="display_name"', 'name="system_prompt"', 'name="capability"',
|
||||||
|
'name="group_ids"', 'name="position"'):
|
||||||
|
assert field in page, field
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_detail_page_links_to_its_neighbours(client: TestClient, db, registered):
|
||||||
|
_many_models(db, 3)
|
||||||
|
first, middle, last = db.scalars(select(Model).order_by(Model.position)).all()
|
||||||
|
|
||||||
|
page = client.get(f"/admin/models/{middle.id}/edit").text
|
||||||
|
assert f"/admin/models/{first.id}/edit" in page
|
||||||
|
assert f"/admin/models/{last.id}/edit" in page
|
||||||
|
assert "2 of 3" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_model_detail_is_404(client: TestClient, db, registered):
|
||||||
|
assert client.get("/admin/models/nope/edit").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_from_the_detail_page_returns_to_it(client: TestClient, db, registered):
|
||||||
|
_many_models(db, 2)
|
||||||
|
model = db.scalar(select(Model).where(Model.model_id == "model-000"))
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/admin/models/{model.id}",
|
||||||
|
data={"display_name": "Renamed", "enabled": "true", "public": "true"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.headers["location"].startswith(f"/admin/models/{model.id}/edit")
|
||||||
|
|
||||||
|
db.refresh(model)
|
||||||
|
assert model.display_name == "Renamed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_typing_a_position_moves_the_model(client: TestClient, db, registered):
|
||||||
|
"""Up/down is unusable for moving a model 60 places."""
|
||||||
|
_many_models(db, 5)
|
||||||
|
last = db.scalar(select(Model).where(Model.model_id == "model-004"))
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/admin/models/{last.id}",
|
||||||
|
data={"display_name": "", "enabled": "true", "public": "true", "position": "1"},
|
||||||
|
)
|
||||||
|
order = [m.model_id for m in db.scalars(select(Model).order_by(Model.position))]
|
||||||
|
assert order[0] == "model-004"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_nonsense_position_is_ignored(client: TestClient, db, registered):
|
||||||
|
_many_models(db, 3)
|
||||||
|
model = db.scalar(select(Model).where(Model.model_id == "model-000"))
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/admin/models/{model.id}",
|
||||||
|
data={"display_name": "", "enabled": "true", "public": "true", "position": "abc"},
|
||||||
|
)
|
||||||
|
db.refresh(model)
|
||||||
|
assert model.position == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_returns_to_the_filtered_view(client: TestClient, db, registered):
|
||||||
|
_many_models(db, 3)
|
||||||
|
model = db.scalar(select(Model).where(Model.model_id == "model-001"))
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/admin/models/{model.id}/move",
|
||||||
|
data={"direction": "up", "back": "/admin/models?filter=enabled&page=2"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.headers["location"] == "/admin/models?filter=enabled&page=2"
|
||||||
|
|||||||
Reference in New Issue
Block a user