From aa0bbe524a80c7813d84a1c59789c0faae3a19c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 1 Aug 2026 00:51:57 +0200 Subject: [PATCH] An admin inspector on the right of the chat A third child of .shell, opening and closing like the sidebar opposite it, showing the system message that would go out, the tools offered, what the last reply cost, and the whole request body as JSON. Rebuilt, not recorded. Recording every request would store a copy of the growing conversation against every message -- quadratic in chat length -- and the thing an administrator debugging a bad answer actually wants is what the current configuration produces. The panel says exactly that at the top, so nobody mistakes it for forensics. Owner-checked and admin-checked, not admin alone. permissions.resolve giving an admin everything is about configuration, which they can grant themselves anyway; reading someone's conversation is a different act, and it is why sharing.visible_to has no admin branch. An inspector that could dump any user's transcript would be that branch under another name. No new JavaScript. app.js already delegates [data-toggle], and hx-trigger="intersect once" makes the load lazy for free: a hidden element never intersects, so the request fires the first time it is opened and never on a page load nobody looked at. Image data URIs are replaced before dumping -- fidelity is the point, but not several megabytes of base64 in the DOM. Everything renders through normal escaping and never |safe: this JSON is full of model output, search results and uploaded documents. Co-Authored-By: Claude Opus 5 (1M context) --- src/lembas/api/chats.py | 84 ++++++++++ src/lembas/web/static/css/app.css | 90 +++++++++++ src/lembas/web/static/css/tokens.css | 1 + src/lembas/web/templates/chat/_inspector.html | 24 +++ .../web/templates/chat/_inspector_body.html | 90 +++++++++++ src/lembas/web/templates/chat/index.html | 12 ++ tests/test_inspector.py | 144 ++++++++++++++++++ 7 files changed, 445 insertions(+) create mode 100644 src/lembas/web/templates/chat/_inspector.html create mode 100644 src/lembas/web/templates/chat/_inspector_body.html create mode 100644 tests/test_inspector.py diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index b879a60..d349d06 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -23,6 +23,7 @@ from lembas.services import files as files_service from lembas.services import generation as generation_service from lembas.services import metrics as metrics_service from lembas.services import sse +from lembas.services import tools as tools_service from lembas.services.markdown import escape_text, render_markdown from lembas.web.templating import render, templates @@ -118,6 +119,89 @@ async def start_chat( # /start when the first message is actually sent. +@router.get("/{chat_id}/inspect") +async def inspect_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response: + """What this chat would send upstream right now. + + Owner-checked *and* admin-checked, not admin alone. `permissions.resolve` + giving an admin everything is about configuration, which they can grant + themselves anyway; reading someone's conversation is a different act, which + is why `sharing.visible_to` has no admin branch either. An inspector that + could dump any user's transcript would be that branch under another name. + + Rebuilt, not recorded. Recording every request would store a copy of the + whole conversation against every message, which grows quadratically with + chat length -- and the thing an administrator actually wants to see is what + the current configuration produces. The panel says so in as many words. + """ + chat = _owned_chat(db, chat_id, user.id) + if not user.is_admin: + raise HTTPException( + status.HTTP_403_FORBIDDEN, "The inspector is restricted to administrators." + ) + + offered = tools_service.enabled_tools(db, chat, user) + payload = chat_service.build_request(db, chat, tools=offered, user=user) + last = db.scalar( + select(Message) + .where(Message.chat_id == chat.id, Message.role == ROLE_ASSISTANT) + .order_by(Message.created_at.desc()) + ) + + messages = payload.get("messages") or [] + system = messages[0]["content"] if messages and messages[0].get("role") == "system" else "" + + return render( + request, + "chat/_inspector_body.html", + { + "chat": chat, + "system": system, + "request_json": _pretty(_redact(payload)), + "row": last, + "metrics": metrics_service.from_message(last.usage_json if last else None), + "tool_names": [ + (t.get("function") or {}).get("name", "") for t in offered + ], + "model": chat_service.model_for(db, chat), + }, + ) + + +# Roughly what a downscaled phone photo comes to as base64. The exact figure +# does not matter; putting megabytes of it into the DOM does. +_REDACTED_URI = "data:…base64 image omitted…" +MAX_INSPECT_CHARS = 40_000 + + +def _redact(payload: dict) -> dict: + """Replace image data URIs before dumping. + + Nothing else is hidden -- fidelity is the whole point of the panel, and API + keys never appear because `build_request` returns a body, not headers. + """ + messages = [] + for message in payload.get("messages") or []: + content = message.get("content") + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "image_url": + parts.append({"type": "image_url", "image_url": {"url": _REDACTED_URI}}) + else: + parts.append(part) + message = {**message, "content": parts} + messages.append(message) + return {**payload, "messages": messages} + + +def _pretty(payload: dict) -> str: + text = json.dumps(payload, indent=2, ensure_ascii=False, default=str) + if len(text) > MAX_INSPECT_CHARS: + return text[:MAX_INSPECT_CHARS] + "\n… truncated" + return text + + @router.post("/{chat_id}/keep") async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response: """Stop a temporary chat being temporary. diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index ae33297..0ad1998 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -420,6 +420,96 @@ button, input, textarea, select { background: var(--bg); } +/* The inspector, mirroring the sidebar on the other side of .shell. Hidden by + the `hidden` attribute, which the rule at the top of this file forces to win. */ +.inspector { + width: var(--inspector-width); + flex: none; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-sunken); + border-left: 1px solid var(--border); +} + +.inspector__header { + display: flex; + align-items: center; + gap: var(--sp-2); + height: var(--header-height); + flex: none; + padding: 0 var(--sp-3); + border-bottom: 1px solid var(--border); +} +.inspector__title { + display: flex; + align-items: center; + gap: var(--sp-2); + flex: 1; + font-size: var(--text-sm); + font-weight: 600; + color: var(--ink-muted); +} + +.inspector__body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--sp-4); + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +.inspector__heading { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--text-sm); + margin: var(--sp-5) 0 var(--sp-2); +} +.inspector__note { + font-size: var(--text-xs); + color: var(--ink-muted); + line-height: var(--leading-normal); + margin: 0 0 var(--sp-3); + overflow-wrap: anywhere; +} + +.inspector__facts { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--sp-1) var(--sp-3); + font-size: var(--text-xs); + margin: 0; +} +.inspector__facts dt { color: var(--ink-faint); } +.inspector__facts dd { margin: 0; overflow-wrap: anywhere; } + +.inspector__json { + margin: 0 0 var(--sp-3); + padding: var(--sp-2); + max-height: 22rem; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: var(--leading-normal); + color: var(--ink-muted); +} + +@media (max-width: 64rem) { + .inspector { + position: fixed; + inset: 0 0 0 auto; + z-index: 40; + box-shadow: var(--shadow-lg); + } +} + .topbar { display: flex; align-items: center; diff --git a/src/lembas/web/static/css/tokens.css b/src/lembas/web/static/css/tokens.css index b73bcc0..a03b140 100644 --- a/src/lembas/web/static/css/tokens.css +++ b/src/lembas/web/static/css/tokens.css @@ -65,6 +65,7 @@ /* --- Layout ----------------------------------------------------------- */ --sidebar-width: 17.5rem; + --inspector-width: 24rem; --thread-max-width: 48rem; --header-height: 3.5rem; diff --git a/src/lembas/web/templates/chat/_inspector.html b/src/lembas/web/templates/chat/_inspector.html new file mode 100644 index 0000000..40838ae --- /dev/null +++ b/src/lembas/web/templates/chat/_inspector.html @@ -0,0 +1,24 @@ +{% from "_macros.html" import icon %} +{# + The request inspector: a third child of .shell, opening and closing like the + sidebar opposite it. Administrators only, and only on their own chats. + + `intersect once` is what makes it lazy with no JavaScript: a hidden element + never intersects the viewport, so the request fires the first time it is + opened and never on a page load nobody looked at. +#} + diff --git a/src/lembas/web/templates/chat/_inspector_body.html b/src/lembas/web/templates/chat/_inspector_body.html new file mode 100644 index 0000000..d799d1a --- /dev/null +++ b/src/lembas/web/templates/chat/_inspector_body.html @@ -0,0 +1,90 @@ +{% from "_macros.html" import icon %} +{# + Everything is rendered through normal Jinja escaping and never `|safe`. This + JSON is full of model output, search results and uploaded documents -- hard + rule 6 applies here exactly as it does to a chat bubble. +#} +

+ Rebuilt now against the current configuration. This is not a recording of the + request that produced the last reply — if a prompt or a setting has changed + since, this shows what would be sent today. +

+ +
+ +
+ +

This chat

+
+
Model
{{ chat.model_id or "—" }}
+
Context
+
+ {% if model and model.context_length %} + {{ model.context_length }} tokens + {% else %} + {# Shown here rather than only beside a reply, because "why is there no + percentage" is a question people have before the first one. #} + not set{% if model %} — set it{% endif %} + {% endif %} +
+ {% if chat.temporary %}
Lifetime
temporary
{% endif %} +
+ +

Last reply

+{% if row %} +
+
Tokens
+
+ {% if metrics.has_anything %} + {% if metrics.estimated %}~{% endif %}{{ metrics.prompt_tokens }} in, + {% if metrics.estimated %}~{% endif %}{{ metrics.completion_tokens }} out + {% if metrics.estimated %}estimated{% endif %} + {% else %} + not reported + {% endif %} +
+
Elapsed
{{ metrics.elapsed_ms }} ms
+
Thinking
{{ row.reasoning_ms }} ms
+
Tool calls
{{ row.tool_calls_json | length }}
+
Rounds
{{ metrics.rounds }}
+
State
+
+ {% if row.error %}error + {% elif row.stopped %}stopped + {% elif not row.complete %}writing + {% else %}complete{% endif %} +
+
+{% if row.error %} +

{{ row.error }}

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

Nothing has been answered in this chat yet.

+{% endif %} + +

System message

+{% if system %} +
{{ system }}
+{% else %} +

None is being sent.

+{% endif %} + +

+ Tools offered + {{ tool_names | length }} +

+{% if tool_names %} +

{{ tool_names | join(", ") }}

+{% else %} +

+ None. A model has to be marked as supporting tools, the user needs the + permission, and the tool itself has to be turned on. +

+{% endif %} + +

Request body

+
{{ request_json }}
diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html index d4d20a6..e776516 100644 --- a/src/lembas/web/templates/chat/index.html +++ b/src/lembas/web/templates/chat/index.html @@ -65,6 +65,13 @@ {{ icon("sliders") }} {% endif %} + + {% if chat and user.is_admin %} + + {% endif %} @@ -213,5 +220,10 @@ {% include "chat/_composer.html" %} {% endif %} + + {# A third child of .shell, mirroring the sidebar opposite it. #} + {% if chat and user.is_admin %} + {% include "chat/_inspector.html" %} + {% endif %} {% endblock %} diff --git a/tests/test_inspector.py b/tests/test_inspector.py new file mode 100644 index 0000000..e059e81 --- /dev/null +++ b/tests/test_inspector.py @@ -0,0 +1,144 @@ +"""The admin request inspector.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from lembas.db.models import Chat, Connection, Message, Model, User +from lembas.services import settings_store +from lembas.services.crypto import encrypt + + +def _connection(db, **model_kwargs) -> 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", **model_kwargs)) + db.commit() + + +@pytest.fixture +def plain_user(client: TestClient, db, registered) -> User: + """A second, non-admin account, left signed in.""" + 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")) + + +# --- Access ------------------------------------------------------------------- +def test_an_admin_sees_the_toggle(client: TestClient, db, registered, make_chat): + _connection(db) + page = client.get(f"/chat/{make_chat()}").text + assert 'data-toggle="#inspector"' in page + assert 'id="inspector"' in page + + +def test_a_plain_user_has_no_inspector(client: TestClient, db, plain_user, make_chat): + _connection(db) + chat_id = make_chat(email="sam@shire.test") + page = client.get(f"/chat/{chat_id}").text + assert 'data-toggle="#inspector"' not in page + assert 'id="inspector"' not in page + + +def test_a_plain_user_is_refused_the_endpoint(client: TestClient, db, plain_user, make_chat): + """Owning the chat is not enough. Reading a conversation is a different act + from configuring the instance, which is why sharing has no admin branch + either -- and this must not become one by another name.""" + _connection(db) + chat_id = make_chat(email="sam@shire.test") + assert client.get(f"/api/chats/{chat_id}/inspect").status_code == 403 + + +def test_an_admin_cannot_inspect_someone_elses_chat( + client: TestClient, db, registered, make_chat +): + _connection(db) + chat_id = make_chat() + other = User(name="Sam", email="s@shire.test", password_hash="x") + db.add(other) + db.commit() + db.get(Chat, chat_id).user_id = other.id + db.commit() + + assert client.get(f"/api/chats/{chat_id}/inspect").status_code == 404 + + +# --- What it shows ------------------------------------------------------------ +def test_it_says_it_is_rebuilt_not_recorded(client: TestClient, db, registered, make_chat): + _connection(db) + body = client.get(f"/api/chats/{make_chat()}/inspect").text + assert "not a recording" in body + + +def test_it_shows_the_system_message_and_the_request( + client: TestClient, db, registered, make_chat +): + settings_store.update(db, {"system_prompt": "Speak as Gandalf."}) + _connection(db, context_length=8192) + chat_id = make_chat() + db.add(Message(chat_id=chat_id, role="user", content="what is lembas?")) + db.commit() + + body = client.get(f"/api/chats/{chat_id}/inspect").text + assert "Speak as Gandalf." in body + assert "test-model" in body + assert "what is lembas?" in body + assert "8192 tokens" in body + + +def test_model_output_is_escaped(client: TestClient, db, registered, make_chat): + """The JSON is full of model output and search results. Hard rule 6 applies + here exactly as it does to a chat bubble.""" + _connection(db) + chat_id = make_chat() + db.add(Message(chat_id=chat_id, role="assistant", content="")) + db.commit() + + body = client.get(f"/api/chats/{chat_id}/inspect").text + assert "" not in body + assert "<script>" in body + + +def test_an_image_is_not_dumped_into_the_dom( + client: TestClient, db, registered, make_chat +): + """A phone photo is megabytes of base64. Fidelity is the point of the panel, + but not that much of it.""" + import io + + from PIL import Image + + from lembas.db.models import Attachment + + _connection(db, capabilities_json={"vision": True}) + chat_id = make_chat() + + buffer = io.BytesIO() + Image.new("RGB", (80, 60), "red").save(buffer, format="PNG") + client.post("/api/files", files={"file": ("p.png", buffer.getvalue(), "image/png")}) + attachment = db.scalar(select(Attachment)) + + message = Message(chat_id=chat_id, role="user", content="look") + db.add(message) + db.commit() + attachment.message_id = message.id + attachment.chat_id = chat_id + db.commit() + + body = client.get(f"/api/chats/{chat_id}/inspect").text + assert "base64 image omitted" in body + assert "iVBORw0" not in body + + +def test_a_chat_with_no_reply_yet_says_so(client: TestClient, db, registered, make_chat): + _connection(db) + assert "Nothing has been answered" in client.get(f"/api/chats/{make_chat()}/inspect").text