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:
@@ -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;
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
|
||||
/* --- Layout ----------------------------------------------------------- */
|
||||
--sidebar-width: 17.5rem;
|
||||
--inspector-width: 24rem;
|
||||
--thread-max-width: 48rem;
|
||||
--header-height: 3.5rem;
|
||||
|
||||
|
||||
@@ -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.
|
||||
#}
|
||||
<aside class="inspector" id="inspector" hidden aria-label="Request inspector">
|
||||
<div class="inspector__header">
|
||||
<h2 class="inspector__title">{{ icon("search", "icon--sm") }} Inspector</h2>
|
||||
<button class="btn btn--icon btn--sm" type="button" data-toggle="#inspector"
|
||||
aria-label="Close inspector">
|
||||
{{ icon("x", "icon--sm") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inspector__body" id="inspector-body"
|
||||
hx-get="/api/chats/{{ chat.id }}/inspect"
|
||||
hx-trigger="intersect once" hx-target="this" hx-swap="innerHTML">
|
||||
<p class="inspector__note">Opening…</p>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -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.
|
||||
#}
|
||||
<p class="inspector__note">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--sm" type="button"
|
||||
hx-get="/api/chats/{{ chat.id }}/inspect"
|
||||
hx-target="#inspector-body" hx-swap="innerHTML">
|
||||
{{ icon("refresh", "icon--sm") }} Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="inspector__heading">This chat</h3>
|
||||
<dl class="inspector__facts">
|
||||
<dt>Model</dt><dd>{{ chat.model_id or "—" }}</dd>
|
||||
<dt>Context</dt>
|
||||
<dd>
|
||||
{% 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 %} — <a href="/admin/models/{{ model.id }}/edit">set it</a>{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if chat.temporary %}<dt>Lifetime</dt><dd>temporary</dd>{% endif %}
|
||||
</dl>
|
||||
|
||||
<h3 class="inspector__heading">Last reply</h3>
|
||||
{% if row %}
|
||||
<dl class="inspector__facts">
|
||||
<dt>Tokens</dt>
|
||||
<dd>
|
||||
{% if metrics.has_anything %}
|
||||
{% if metrics.estimated %}~{% endif %}{{ metrics.prompt_tokens }} in,
|
||||
{% if metrics.estimated %}~{% endif %}{{ metrics.completion_tokens }} out
|
||||
{% if metrics.estimated %}<span class="badge">estimated</span>{% endif %}
|
||||
{% else %}
|
||||
not reported
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Elapsed</dt><dd>{{ metrics.elapsed_ms }} ms</dd>
|
||||
<dt>Thinking</dt><dd>{{ row.reasoning_ms }} ms</dd>
|
||||
<dt>Tool calls</dt><dd>{{ row.tool_calls_json | length }}</dd>
|
||||
<dt>Rounds</dt><dd>{{ metrics.rounds }}</dd>
|
||||
<dt>State</dt>
|
||||
<dd>
|
||||
{% if row.error %}<span class="badge badge--danger">error</span>
|
||||
{% elif row.stopped %}<span class="badge badge--warning">stopped</span>
|
||||
{% elif not row.complete %}<span class="badge">writing</span>
|
||||
{% else %}<span class="badge badge--success">complete</span>{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
{% if row.error %}
|
||||
<p class="inspector__note">{{ row.error }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="inspector__note">Nothing has been answered in this chat yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="inspector__heading">System message</h3>
|
||||
{% if system %}
|
||||
<pre class="inspector__json">{{ system }}</pre>
|
||||
{% else %}
|
||||
<p class="inspector__note">None is being sent.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="inspector__heading">
|
||||
Tools offered
|
||||
<span class="badge">{{ tool_names | length }}</span>
|
||||
</h3>
|
||||
{% if tool_names %}
|
||||
<p class="inspector__note mono">{{ tool_names | join(", ") }}</p>
|
||||
{% else %}
|
||||
<p class="inspector__note">
|
||||
None. A model has to be marked as supporting tools, the user needs the
|
||||
permission, and the tool itself has to be turned on.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="inspector__heading">Request body</h3>
|
||||
<pre class="inspector__json">{{ request_json }}</pre>
|
||||
@@ -65,6 +65,13 @@
|
||||
{{ icon("sliders") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if chat and user.is_admin %}
|
||||
<button class="btn btn--icon" type="button" aria-label="Inspect this chat"
|
||||
title="Inspect this chat" aria-expanded="false" data-toggle="#inspector">
|
||||
{{ icon("search") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -213,5 +220,10 @@
|
||||
{% include "chat/_composer.html" %}
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
{# A third child of .shell, mirroring the sidebar opposite it. #}
|
||||
{% if chat and user.is_admin %}
|
||||
{% include "chat/_inspector.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user