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. +
+ +{{ row.error }}
+{% endif %} +{% else %} +Nothing has been answered in this chat yet.
+{% endif %} + +{{ system }}
+{% else %}
+None is being sent.
+{% endif %} + +{{ 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_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