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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user