598 lines
20 KiB
JavaScript
598 lines
20 KiB
JavaScript
import { html, nothing } from 'lit';
|
|
import { LightElement } from '../lib/base.js';
|
|
|
|
const PAGE_ID = 'session';
|
|
|
|
function formatDate(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
day: '2-digit', month: '2-digit', year: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatTime(iso) {
|
|
if (!iso) return null;
|
|
return new Date(iso).toLocaleTimeString('en-GB', {
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
});
|
|
}
|
|
|
|
function sourceBadgeClass(source) {
|
|
const map = { tic: 'bg-warning text-dark', cron: 'bg-info text-dark', web: 'bg-primary', telegram: 'bg-success', mobile: 'bg-secondary' };
|
|
return map[source] ?? 'bg-secondary';
|
|
}
|
|
|
|
function jsonPretty(val) {
|
|
if (val == null) return '—';
|
|
if (typeof val === 'string') return val;
|
|
return JSON.stringify(val, null, 2);
|
|
}
|
|
|
|
export class SessionDetailPage extends LightElement {
|
|
static properties = {
|
|
_open: { state: true },
|
|
_sessionId: { state: true },
|
|
_data: { state: true },
|
|
_loading: { state: true },
|
|
_error: { state: true },
|
|
_live: { state: true },
|
|
_expandedTools: { state: true },
|
|
_expandedReasons: { state: true },
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
this._open = false;
|
|
this._sessionId = null;
|
|
this._data = null;
|
|
this._loading = false;
|
|
this._error = null;
|
|
this._live = false;
|
|
this._expandedTools = new Set();
|
|
this._expandedReasons = new Set();
|
|
this._ws = null;
|
|
this._wsReconnectTimer = null;
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
window.addEventListener('llm-page-change', (e) => {
|
|
this._open = e.detail.page === PAGE_ID;
|
|
this.style.display = this._open ? 'flex' : 'none';
|
|
if (this._open) this._loadFromHash();
|
|
else this._closeWs();
|
|
});
|
|
window.addEventListener('hashchange', () => {
|
|
if (this._open) this._loadFromHash();
|
|
});
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this._closeWs();
|
|
}
|
|
|
|
_idFromHash() {
|
|
const parts = location.hash.replace('#', '').split('/');
|
|
if (parts[0] === PAGE_ID && parts[1]) return parseInt(parts[1], 10);
|
|
return null;
|
|
}
|
|
|
|
_loadFromHash() {
|
|
const id = this._idFromHash();
|
|
if (id != null && id !== this._sessionId) {
|
|
this._sessionId = id;
|
|
this._closeWs();
|
|
this._fetch(id);
|
|
}
|
|
}
|
|
|
|
async _fetch(id) {
|
|
this._loading = true;
|
|
this._error = null;
|
|
this._data = null;
|
|
this._expandedTools = new Set();
|
|
this._expandedReasons = new Set();
|
|
try {
|
|
const res = await fetch(`/api/sessions/${id}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
this._data = await res.json();
|
|
this._connectWs(id);
|
|
} catch (e) {
|
|
this._error = e.message;
|
|
} finally {
|
|
this._loading = false;
|
|
}
|
|
}
|
|
|
|
// ── Live WebSocket ─────────────────────────────────────────────────────────
|
|
|
|
_connectWs(id) {
|
|
this._closeWs();
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const ws = new WebSocket(`${proto}://${location.host}/api/ws/session/${id}`);
|
|
this._ws = ws;
|
|
|
|
ws.onopen = () => { this._live = true; };
|
|
|
|
ws.onmessage = (e) => {
|
|
try { this._handleEvent(JSON.parse(e.data)); } catch {}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
this._live = false;
|
|
this._ws = null;
|
|
// Reconnect after 3 s if the page is still open and showing this session.
|
|
if (this._open && this._sessionId === id) {
|
|
this._wsReconnectTimer = setTimeout(() => this._connectWs(id), 3000);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => ws.close();
|
|
}
|
|
|
|
_closeWs() {
|
|
clearTimeout(this._wsReconnectTimer);
|
|
if (this._ws) { this._ws.onclose = null; this._ws.close(); this._ws = null; }
|
|
this._live = false;
|
|
}
|
|
|
|
_handleEvent(ev) {
|
|
if (!this._data) return;
|
|
const msgs = [...this._data.messages];
|
|
const now = new Date().toISOString();
|
|
|
|
switch (ev.type) {
|
|
case 'tool_start':
|
|
msgs.push({
|
|
kind: 'tool',
|
|
tool_call_id: ev.tool_call_id,
|
|
message_id: ev.message_id,
|
|
name: ev.name,
|
|
label_short: ev.label_short,
|
|
label_full: ev.label_full,
|
|
arguments: ev.arguments,
|
|
result: null,
|
|
error: null,
|
|
status: 'pending',
|
|
created_at: now,
|
|
});
|
|
break;
|
|
|
|
case 'tool_done': {
|
|
const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id);
|
|
if (i >= 0) msgs[i] = { ...msgs[i], result: ev.result, status: 'done' };
|
|
break;
|
|
}
|
|
|
|
case 'tool_error': {
|
|
const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id);
|
|
if (i >= 0) msgs[i] = { ...msgs[i], error: ev.error, status: 'error' };
|
|
break;
|
|
}
|
|
|
|
case 'thinking':
|
|
msgs.push({
|
|
kind: 'thinking',
|
|
message_id: ev.message_id,
|
|
content: ev.content,
|
|
reasoning: '',
|
|
input_tokens: ev.input_tokens ?? null,
|
|
output_tokens: ev.output_tokens ?? null,
|
|
created_at: now,
|
|
});
|
|
break;
|
|
|
|
case 'done':
|
|
msgs.push({
|
|
kind: 'assistant',
|
|
message_id: ev.message_id,
|
|
content: ev.content,
|
|
reasoning: '',
|
|
input_tokens: ev.input_tokens ?? null,
|
|
output_tokens: ev.output_tokens ?? null,
|
|
created_at: now,
|
|
});
|
|
break;
|
|
|
|
case 'user_message':
|
|
msgs.push({
|
|
kind: 'user',
|
|
content: ev.content,
|
|
is_synthetic: false,
|
|
created_at: now,
|
|
});
|
|
break;
|
|
|
|
case 'agent_start':
|
|
msgs.push({
|
|
kind: 'agent',
|
|
agent_id: ev.agent_id,
|
|
depth: ev.depth,
|
|
});
|
|
break;
|
|
|
|
case 'agent_done':
|
|
msgs.push({
|
|
kind: 'agent_end',
|
|
agent_id: ev.agent_id,
|
|
});
|
|
break;
|
|
|
|
default:
|
|
return; // ignore unknown events
|
|
}
|
|
|
|
this._data = { ...this._data, messages: msgs };
|
|
}
|
|
|
|
_toggleTool(id) {
|
|
const next = new Set(this._expandedTools);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
this._expandedTools = next;
|
|
}
|
|
|
|
_toggleReason(id) {
|
|
const next = new Set(this._expandedReasons);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
this._expandedReasons = next;
|
|
}
|
|
|
|
// ── Renderers ─────────────────────────────────────────────────────────────────
|
|
|
|
_back() {
|
|
history.back();
|
|
}
|
|
|
|
_renderSessionHeader(session) {
|
|
return html`
|
|
<div class="sd-session-header">
|
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
<button class="btn btn-sm btn-outline-secondary sd-back-btn" @click=${() => this._back()}>
|
|
<i class="bi bi-arrow-left"></i> Back
|
|
</button>
|
|
<span class="badge ${sourceBadgeClass(session.source)}">${session.source}</span>
|
|
<span class="fw-semibold font-monospace">agent: ${session.agent_id}</span>
|
|
<span class="text-secondary small">id: ${session.id}</span>
|
|
${session.is_ephemeral ? html`<span class="badge bg-light text-dark border">ephemeral</span>` : nothing}
|
|
${!session.is_interactive ? html`<span class="badge bg-light text-dark border">automated</span>` : nothing}
|
|
${this._live
|
|
? html`<span class="sd-live-badge"><span class="sd-live-dot"></span>live</span>`
|
|
: nothing}
|
|
</div>
|
|
<div class="text-secondary small mt-1">${formatDate(session.created_at)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderUserMsg(item, idx) {
|
|
const time = formatTime(item.created_at);
|
|
return html`
|
|
<div class="sd-msg sd-msg--user ${item.is_synthetic ? 'sd-msg--synthetic' : ''}">
|
|
<div class="sd-msg-role">
|
|
${item.is_synthetic
|
|
? html`<span class="badge bg-warning text-dark me-1" style="font-size:0.65rem">synthetic</span>`
|
|
: nothing}
|
|
<span>User</span>
|
|
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
|
</div>
|
|
<div class="sd-msg-content">${item.content}</div>
|
|
${item.failed ? html`<div class="sd-msg-failed">failed</div>` : nothing}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderAssistantMsg(item, idx) {
|
|
const key = `ast-${idx}`;
|
|
const hasReasoning = item.reasoning && item.reasoning.trim().length > 0;
|
|
const expanded = this._expandedReasons.has(key);
|
|
const time = formatTime(item.created_at);
|
|
return html`
|
|
<div class="sd-msg sd-msg--assistant ${item.failed ? 'sd-msg--failed' : ''}">
|
|
<div class="sd-msg-role">
|
|
Assistant
|
|
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
|
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}↑ ${item.output_tokens}↓</span>` : nothing}
|
|
</div>
|
|
${hasReasoning ? html`
|
|
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
|
|
<i class="bi bi-brain me-1"></i>
|
|
Reasoning
|
|
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
|
</div>
|
|
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
|
` : nothing}
|
|
${item.content ? html`<div class="sd-msg-content">${item.content}</div>` : nothing}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderThinkingMsg(item, idx) {
|
|
const key = `think-${item.message_id ?? idx}`;
|
|
const hasReasoning = item.reasoning && item.reasoning.trim().length > 0;
|
|
const expanded = this._expandedReasons.has(key);
|
|
const time = formatTime(item.created_at);
|
|
return html`
|
|
<div class="sd-msg sd-msg--thinking ${item.failed ? 'sd-msg--failed' : ''}">
|
|
<div class="sd-msg-role">
|
|
<i class="bi bi-lightning-charge me-1"></i>Thinking
|
|
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
|
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}↑ ${item.output_tokens}↓</span>` : nothing}
|
|
</div>
|
|
${hasReasoning ? html`
|
|
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
|
|
<i class="bi bi-brain me-1"></i>
|
|
Reasoning
|
|
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
|
</div>
|
|
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
|
` : nothing}
|
|
${item.content ? html`<div class="sd-msg-content">${item.content}</div>` : nothing}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderToolMsg(item, idx) {
|
|
const key = item.tool_call_id ?? idx;
|
|
const expanded = this._expandedTools.has(key);
|
|
const statusClass = { done: 'sd-tool--done', error: 'sd-tool--error', pending: 'sd-tool--pending' }[item.status] ?? '';
|
|
return html`
|
|
<div class="sd-tool ${statusClass}">
|
|
<div class="sd-tool-header" @click=${() => this._toggleTool(key)}>
|
|
<span class="sd-tool-icon">
|
|
<i class="bi bi-${item.status === 'done' ? 'check-circle' : item.status === 'error' ? 'x-circle' : 'hourglass-split'}"></i>
|
|
</span>
|
|
<span class="sd-tool-name">${item.label_short ?? item.name}</span>
|
|
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-auto"></i>
|
|
</div>
|
|
${expanded ? html`
|
|
<div class="sd-tool-body">
|
|
${item.label_full && item.label_full !== item.label_short
|
|
? html`<div class="sd-tool-label-full text-secondary small mb-2">${item.label_full}</div>`
|
|
: nothing}
|
|
<div class="sd-tool-section-label">Arguments</div>
|
|
<pre class="sd-code-block">${jsonPretty(item.arguments)}</pre>
|
|
<div class="sd-tool-section-label mt-2">
|
|
${item.status === 'error' ? 'Error' : 'Result'}
|
|
</div>
|
|
<pre class="sd-code-block ${item.status === 'error' ? 'sd-code-block--error' : ''}">${
|
|
item.result ?? item.error ?? '—'
|
|
}</pre>
|
|
</div>
|
|
` : nothing}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderAgentFrame(item) {
|
|
return html`
|
|
<div class="sd-agent-frame-start">
|
|
<i class="bi bi-robot me-1"></i>
|
|
<span>Sub-agent: <strong>${item.agent_id}</strong></span>
|
|
<span class="text-secondary small ms-2">depth ${item.depth}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderAgentFrameEnd(item) {
|
|
return html`<div class="sd-agent-frame-end">end of ${item.agent_id}</div>`;
|
|
}
|
|
|
|
_renderMessage(item, idx) {
|
|
switch (item.kind) {
|
|
case 'user': return this._renderUserMsg(item, idx);
|
|
case 'assistant': return this._renderAssistantMsg(item, idx);
|
|
case 'thinking': return this._renderThinkingMsg(item, idx);
|
|
case 'tool': return this._renderToolMsg(item, idx);
|
|
case 'agent': return this._renderAgentFrame(item);
|
|
case 'agent_end': return this._renderAgentFrameEnd(item);
|
|
default: return nothing;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<style>
|
|
.sd-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
padding: 1.5rem;
|
|
gap: 0;
|
|
}
|
|
.sd-back-btn {
|
|
font-size: 0.8rem;
|
|
padding: 0.2rem 0.6rem;
|
|
}
|
|
.sd-session-header {
|
|
background: var(--bs-tertiary-bg);
|
|
border: 1px solid var(--bs-border-color);
|
|
border-radius: 0.5rem;
|
|
padding: 0.875rem 1rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
.sd-msg {
|
|
border-left: 3px solid transparent;
|
|
padding: 0.625rem 0.875rem;
|
|
margin-bottom: 0.5rem;
|
|
border-radius: 0 0.375rem 0.375rem 0;
|
|
background: var(--bs-body-bg);
|
|
}
|
|
.sd-msg--user { border-left-color: var(--bs-primary); background: var(--bs-tertiary-bg); }
|
|
.sd-msg--synthetic { opacity: 0.75; border-left-color: var(--bs-warning); }
|
|
.sd-msg--assistant { border-left-color: var(--bs-success); }
|
|
.sd-msg--thinking { border-left-color: var(--bs-info); background: var(--bs-tertiary-bg); }
|
|
.sd-msg--failed { border-left-color: var(--bs-danger) !important; opacity: 0.7; }
|
|
.sd-msg-role {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--bs-secondary-color);
|
|
margin-bottom: 0.25rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
}
|
|
.sd-msg-time {
|
|
font-weight: 400;
|
|
font-size: 0.65rem;
|
|
color: var(--bs-secondary-color);
|
|
font-family: var(--bs-font-monospace);
|
|
}
|
|
.sd-tokens {
|
|
font-weight: 400;
|
|
font-size: 0.65rem;
|
|
color: var(--bs-secondary-color);
|
|
margin-left: auto;
|
|
}
|
|
.sd-msg-content {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
font-size: 0.875rem;
|
|
line-height: 1.55;
|
|
}
|
|
.sd-msg-failed { font-size: 0.7rem; color: var(--bs-danger); margin-top: 0.25rem; }
|
|
.sd-reasoning-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
font-size: 0.7rem;
|
|
color: var(--bs-secondary-color);
|
|
border: 1px solid var(--bs-border-color);
|
|
border-radius: 999px;
|
|
padding: 0.125rem 0.5rem;
|
|
margin-bottom: 0.375rem;
|
|
user-select: none;
|
|
}
|
|
.sd-reasoning-toggle:hover { background: var(--bs-tertiary-bg); }
|
|
.sd-reasoning-block {
|
|
background: var(--bs-tertiary-bg);
|
|
border: 1px solid var(--bs-border-color);
|
|
border-radius: 0.375rem;
|
|
padding: 0.625rem 0.75rem;
|
|
font-size: 0.78rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
margin-bottom: 0.5rem;
|
|
max-height: 20rem;
|
|
overflow-y: auto;
|
|
color: var(--bs-secondary-color);
|
|
}
|
|
.sd-tool {
|
|
border: 1px solid var(--bs-border-color);
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 0.375rem;
|
|
}
|
|
.sd-tool--done { border-left: 3px solid var(--bs-success); }
|
|
.sd-tool--error { border-left: 3px solid var(--bs-danger); }
|
|
.sd-tool--pending { border-left: 3px solid var(--bs-warning); }
|
|
.sd-tool-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
font-size: 0.82rem;
|
|
font-weight: 500;
|
|
}
|
|
.sd-tool-header:hover { background: var(--bs-tertiary-bg); }
|
|
.sd-tool-icon { color: var(--bs-secondary-color); }
|
|
.sd-tool-name { font-family: var(--bs-font-monospace); font-size: 0.8rem; }
|
|
.sd-tool-body {
|
|
padding: 0.625rem 0.75rem;
|
|
border-top: 1px solid var(--bs-border-color);
|
|
background: var(--bs-body-bg);
|
|
}
|
|
.sd-tool-section-label {
|
|
font-size: 0.68rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--bs-secondary-color);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.sd-tool-label-full { font-style: italic; }
|
|
.sd-code-block {
|
|
background: var(--bs-tertiary-bg);
|
|
border: 1px solid var(--bs-border-color);
|
|
border-radius: 0.25rem;
|
|
padding: 0.5rem 0.625rem;
|
|
font-size: 0.75rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
max-height: 16rem;
|
|
overflow-y: auto;
|
|
margin: 0;
|
|
}
|
|
.sd-code-block--error { border-color: var(--bs-danger); color: var(--bs-danger); }
|
|
.sd-agent-frame-start {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
font-size: 0.78rem;
|
|
color: var(--bs-secondary-color);
|
|
border-top: 1px dashed var(--bs-border-color);
|
|
padding: 0.375rem 0;
|
|
margin: 0.25rem 0 0.25rem 1rem;
|
|
}
|
|
.sd-agent-frame-end {
|
|
font-size: 0.72rem;
|
|
color: var(--bs-secondary-color);
|
|
border-bottom: 1px dashed var(--bs-border-color);
|
|
padding: 0.25rem 0;
|
|
margin: 0 0 0.375rem 1rem;
|
|
}
|
|
.sd-live-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--bs-success);
|
|
border: 1px solid var(--bs-success);
|
|
border-radius: 999px;
|
|
padding: 0.1rem 0.45rem;
|
|
}
|
|
.sd-live-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--bs-success);
|
|
animation: sd-pulse 1.4s ease-in-out infinite;
|
|
}
|
|
@keyframes sd-pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.25; }
|
|
}
|
|
</style>
|
|
|
|
<div class="sd-container">
|
|
${this._loading ? html`
|
|
<div class="text-center text-secondary py-5">
|
|
<div class="spinner-border spinner-border-sm me-2"></div>Loading session…
|
|
</div>
|
|
` : this._error ? html`
|
|
<div class="alert alert-danger">${this._error}</div>
|
|
` : !this._data ? html`
|
|
<div class="text-secondary text-center py-5">No session loaded.<br>
|
|
<span class="small">Navigate to <code>#session/{id}</code> to view a session.</span>
|
|
</div>
|
|
` : html`
|
|
${this._renderSessionHeader(this._data.session)}
|
|
${this._data.messages.length === 0
|
|
? html`<div class="text-secondary text-center py-4">No messages in this session.</div>`
|
|
: this._data.messages.map((m, i) => this._renderMessage(m, i))
|
|
}
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|