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