|
|
@@ -3,6 +3,16 @@ const statusEl = document.querySelector("#connection-status");
|
|
|
const chatForm = document.querySelector("#chat-form");
|
|
|
const userMessage = document.querySelector("#user-message");
|
|
|
const runButton = document.querySelector("#run-button");
|
|
|
+const sessionTitle = document.querySelector("#session-title");
|
|
|
+const sessionList = document.querySelector("#session-list");
|
|
|
+const sessionStatus = document.querySelector("#session-status");
|
|
|
+const auditReplay = document.querySelector("#audit-replay");
|
|
|
+const auditCount = document.querySelector("#audit-count");
|
|
|
+const auditSummaryEl = document.querySelector("#audit-summary");
|
|
|
+const auditDetail = document.querySelector("#audit-detail");
|
|
|
+const sessionUsageSummary = document.querySelector("#session-usage-summary");
|
|
|
+const sessionUsageTurns = document.querySelector("#session-usage-turns");
|
|
|
+const sessionUsageCalls = document.querySelector("#session-usage-calls");
|
|
|
const snapshotName = document.querySelector("#workspace-snapshot-name");
|
|
|
const savedSnapshots = document.querySelector("#saved-workspace-snapshots");
|
|
|
const systemPrompts = document.querySelector("#system-prompts");
|
|
|
@@ -12,6 +22,7 @@ const toolList = document.querySelector("#tool-list");
|
|
|
const toolCount = document.querySelector("#tool-count");
|
|
|
const chatConfigDialog = document.querySelector("#chat-config-dialog");
|
|
|
const eventConfigDialog = document.querySelector("#event-config-dialog");
|
|
|
+const auditDialog = document.querySelector("#audit-dialog");
|
|
|
const WORKSPACE_SNAPSHOTS_STORAGE_KEY = "agent-lab.workspace-snapshots.v1";
|
|
|
const LEGACY_PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
|
|
|
|
|
|
@@ -23,6 +34,12 @@ let elapsedTimer = 0;
|
|
|
let hasBackendRoundStats = false;
|
|
|
let pendingEnabledTools = null;
|
|
|
let availableTools = [];
|
|
|
+let currentSessionId = null;
|
|
|
+let sessions = [];
|
|
|
+let auditEntries = [];
|
|
|
+let selectedAuditKey = null;
|
|
|
+let liveAuditSequence = 0;
|
|
|
+let turnActive = false;
|
|
|
|
|
|
bindClick("#add-system-prompt", () => {
|
|
|
systemPrompts.append(createSystemPrompt({ content: "" }));
|
|
|
@@ -35,6 +52,19 @@ bindClick("#add-pre-message", () => {
|
|
|
bindClick("#save-workspace-snapshot", saveWorkspaceSnapshot);
|
|
|
bindClick("#load-workspace-snapshot", loadWorkspaceSnapshot);
|
|
|
bindClick("#delete-workspace-snapshot", deleteWorkspaceSnapshot);
|
|
|
+bindClick("#new-session", createSession);
|
|
|
+bindClick("#refresh-sessions", () => {
|
|
|
+ loadSessions();
|
|
|
+});
|
|
|
+bindClick("#load-session", () => {
|
|
|
+ loadSelectedSession();
|
|
|
+});
|
|
|
+bindClick("#open-audit-dialog", () => {
|
|
|
+ auditDialog.showModal();
|
|
|
+});
|
|
|
+bindClick("#close-audit-dialog", () => {
|
|
|
+ auditDialog.close();
|
|
|
+});
|
|
|
bindClick("#open-chat-config-panel", openChatConfig);
|
|
|
bindClick("#close-chat-config", () => {
|
|
|
chatConfigDialog.close();
|
|
|
@@ -58,6 +88,9 @@ chatForm.addEventListener("submit", (event) => {
|
|
|
initializePromptList();
|
|
|
refreshWorkspaceSnapshotSelector();
|
|
|
loadTools();
|
|
|
+loadSessions({ preserveStatus: true });
|
|
|
+renderAuditReplay([]);
|
|
|
+renderSessionUsage({ calls: [], turns: [], session: {} });
|
|
|
|
|
|
function openChatConfig() {
|
|
|
chatConfigDialog.showModal();
|
|
|
@@ -86,6 +119,125 @@ async function loadTools() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+async function loadSessions(options = {}) {
|
|
|
+ if (!options.preserveStatus) {
|
|
|
+ setSessionStatus("Refreshing sessions");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ sessions = await fetchJson("/api/sessions");
|
|
|
+ renderSessions();
|
|
|
+ if (!options.preserveStatus) {
|
|
|
+ setSessionStatus("Sessions refreshed");
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (!options.preserveStatus) {
|
|
|
+ setSessionStatus("Failed to load sessions");
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderSessions() {
|
|
|
+ const previous = currentSessionId || sessionList.value;
|
|
|
+ sessionList.textContent = "";
|
|
|
+
|
|
|
+ const placeholder = document.createElement("option");
|
|
|
+ placeholder.value = "";
|
|
|
+ placeholder.textContent = sessions.length ? "Select a session" : "No saved sessions";
|
|
|
+ sessionList.append(placeholder);
|
|
|
+
|
|
|
+ sessions.forEach((session) => {
|
|
|
+ const option = document.createElement("option");
|
|
|
+ option.value = session.id;
|
|
|
+ option.textContent = `${session.title} (${session.turn_count} turns, ${session.total_tokens} tokens)`;
|
|
|
+ sessionList.append(option);
|
|
|
+ });
|
|
|
+
|
|
|
+ if (previous && sessions.some((session) => session.id === previous)) {
|
|
|
+ sessionList.value = previous;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function createSession() {
|
|
|
+ if (hasActiveTurn()) {
|
|
|
+ setSessionStatus("Finish current turn before switching sessions");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ closeSocket();
|
|
|
+ setSessionStatus("Creating session");
|
|
|
+ try {
|
|
|
+ const title = sessionTitle.value.trim() || "New session";
|
|
|
+ const session = await fetchJson("/api/sessions", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "content-type": "application/json" },
|
|
|
+ body: JSON.stringify({
|
|
|
+ title,
|
|
|
+ config: buildWorkspaceSnapshot(),
|
|
|
+ }),
|
|
|
+ });
|
|
|
+ currentSessionId = session.id;
|
|
|
+ sessionTitle.value = session.title;
|
|
|
+ await loadSessions({ preserveStatus: true });
|
|
|
+ await loadSessionReplay(session.id, { preserveStatus: true });
|
|
|
+ setSessionStatus("Session created");
|
|
|
+ } catch (error) {
|
|
|
+ setSessionStatus("Failed to create session");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadSelectedSession() {
|
|
|
+ if (hasActiveTurn()) {
|
|
|
+ setSessionStatus("Finish current turn before switching sessions");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const sessionId = sessionList.value;
|
|
|
+ if (!sessionId) {
|
|
|
+ setSessionStatus("Select a session");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ closeSocket();
|
|
|
+ setSessionStatus("Loading session");
|
|
|
+ currentSessionId = sessionId;
|
|
|
+ await loadSessionReplay(sessionId);
|
|
|
+}
|
|
|
+
|
|
|
+async function loadSessionReplay(sessionId, options = {}) {
|
|
|
+ try {
|
|
|
+ const [messages, audit, usage] = await Promise.all([
|
|
|
+ fetchJson(`/api/sessions/${sessionId}/messages`),
|
|
|
+ fetchJson(`/api/sessions/${sessionId}/audit`),
|
|
|
+ fetchJson(`/api/sessions/${sessionId}/usage`),
|
|
|
+ ]);
|
|
|
+ renderReplayMessages(messages);
|
|
|
+ renderAuditReplay(audit);
|
|
|
+ renderSessionUsage(usage);
|
|
|
+ const session = sessions.find((item) => item.id === sessionId);
|
|
|
+ if (session) {
|
|
|
+ sessionTitle.value = session.title;
|
|
|
+ sessionList.value = sessionId;
|
|
|
+ }
|
|
|
+ if (!options.preserveStatus && !isSocketOpen()) {
|
|
|
+ setSessionStatus("Session loaded");
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (!options.preserveStatus) {
|
|
|
+ setSessionStatus("Failed to load session");
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function setSessionStatus(message) {
|
|
|
+ sessionStatus.textContent = message;
|
|
|
+ statusEl.textContent = message;
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchJson(url, options) {
|
|
|
+ const response = await fetch(url, options);
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`Request failed: ${response.status}`);
|
|
|
+ }
|
|
|
+ return response.json();
|
|
|
+}
|
|
|
+
|
|
|
function renderTools(tools) {
|
|
|
availableTools = tools;
|
|
|
toolList.textContent = "";
|
|
|
@@ -622,21 +774,35 @@ function runDebugSession() {
|
|
|
resetRun();
|
|
|
beginTurn(submittedMessage, "Connecting");
|
|
|
socket = new WebSocket(wsUrl());
|
|
|
- socket.addEventListener("open", () => {
|
|
|
+ const activeSocket = socket;
|
|
|
+ activeSocket.addEventListener("open", () => {
|
|
|
+ if (socket !== activeSocket) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
statusEl.textContent = "Waiting for first token";
|
|
|
- socket.send(JSON.stringify(buildRequest(submittedMessage)));
|
|
|
+ activeSocket.send(JSON.stringify(buildRequest(submittedMessage)));
|
|
|
userMessage.value = "";
|
|
|
});
|
|
|
- socket.addEventListener("message", (event) => {
|
|
|
+ activeSocket.addEventListener("message", (event) => {
|
|
|
+ if (socket !== activeSocket) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
handleServerMessage(JSON.parse(event.data));
|
|
|
});
|
|
|
- socket.addEventListener("close", () => {
|
|
|
+ activeSocket.addEventListener("close", () => {
|
|
|
+ if (socket !== activeSocket) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ socket = null;
|
|
|
statusEl.textContent = "Idle";
|
|
|
stopElapsedTimer();
|
|
|
setRunState(false);
|
|
|
updateElapsed();
|
|
|
});
|
|
|
- socket.addEventListener("error", () => {
|
|
|
+ activeSocket.addEventListener("error", () => {
|
|
|
+ if (socket !== activeSocket) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
appendLog("error", "WebSocket connection failed");
|
|
|
});
|
|
|
}
|
|
|
@@ -645,6 +811,10 @@ function isSocketOpen() {
|
|
|
return socket && socket.readyState === WebSocket.OPEN;
|
|
|
}
|
|
|
|
|
|
+function hasActiveTurn() {
|
|
|
+ return turnActive;
|
|
|
+}
|
|
|
+
|
|
|
function beginTurn(submittedMessage, statusText) {
|
|
|
activeAssistant = null;
|
|
|
firstTokenAt = 0;
|
|
|
@@ -659,6 +829,7 @@ function beginTurn(submittedMessage, statusText) {
|
|
|
|
|
|
function buildRequest(submittedMessage = userMessage.value) {
|
|
|
return {
|
|
|
+ session_id: currentSessionId || undefined,
|
|
|
user_message: submittedMessage,
|
|
|
system_prompts: collectSystemPrompts(),
|
|
|
pre_messages: [...document.querySelectorAll(".pre-message")]
|
|
|
@@ -694,6 +865,10 @@ function selectedTools() {
|
|
|
|
|
|
function handleServerMessage(message) {
|
|
|
if (message.type === "session_started") {
|
|
|
+ if (message.session_id) {
|
|
|
+ currentSessionId = message.session_id;
|
|
|
+ loadSessions({ preserveStatus: true });
|
|
|
+ }
|
|
|
appendLog("session", "Session started");
|
|
|
return;
|
|
|
}
|
|
|
@@ -714,7 +889,7 @@ function handleServerMessage(message) {
|
|
|
return;
|
|
|
}
|
|
|
if (message.type === "audit") {
|
|
|
- appendLog("audit", formatAuditMessage(message));
|
|
|
+ appendAuditEntry(message);
|
|
|
return;
|
|
|
}
|
|
|
if (message.type === "event") {
|
|
|
@@ -737,6 +912,10 @@ function handleServerMessage(message) {
|
|
|
stopElapsedTimer();
|
|
|
setRunState(false);
|
|
|
updateElapsed();
|
|
|
+ if (currentSessionId) {
|
|
|
+ loadSessions({ preserveStatus: true });
|
|
|
+ loadSessionReplay(currentSessionId, { preserveStatus: true });
|
|
|
+ }
|
|
|
return;
|
|
|
}
|
|
|
if (message.type === "done") {
|
|
|
@@ -744,17 +923,14 @@ function handleServerMessage(message) {
|
|
|
statusEl.textContent = "Idle";
|
|
|
stopElapsedTimer();
|
|
|
setRunState(false);
|
|
|
+ if (currentSessionId) {
|
|
|
+ loadSessions({ preserveStatus: true });
|
|
|
+ loadSessionReplay(currentSessionId, { preserveStatus: true });
|
|
|
+ }
|
|
|
closeSocket();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function formatAuditMessage(message) {
|
|
|
- const details = message.details && Object.keys(message.details).length
|
|
|
- ? ` ${JSON.stringify(message.details)}`
|
|
|
- : "";
|
|
|
- return `${message.event}${details}`;
|
|
|
-}
|
|
|
-
|
|
|
function appendAssistantDelta(content) {
|
|
|
if (!firstTokenAt) {
|
|
|
firstTokenAt = performance.now();
|
|
|
@@ -781,6 +957,550 @@ function appendLog(kind, content) {
|
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
|
}
|
|
|
|
|
|
+function renderReplayMessages(messages) {
|
|
|
+ messagesEl.textContent = "";
|
|
|
+ activeAssistant = null;
|
|
|
+ if (!messages.length) {
|
|
|
+ appendLog("session", "No messages yet");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ messages.forEach((message) => {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ const kind = ["user", "assistant", "tool"].includes(message.role)
|
|
|
+ ? message.role
|
|
|
+ : "session";
|
|
|
+ item.className = `message ${kind}`;
|
|
|
+ item.textContent = message.content;
|
|
|
+ messagesEl.append(item);
|
|
|
+ });
|
|
|
+ messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
|
+}
|
|
|
+
|
|
|
+function appendAuditEntry(message) {
|
|
|
+ const entry = normalizeAuditEntry(message);
|
|
|
+ renderAuditReplay([...auditEntries, entry], {
|
|
|
+ selectedKey: auditEntryKey(entry),
|
|
|
+ scrollToEnd: true,
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeAuditEntry(entry) {
|
|
|
+ const details = entry.details || {};
|
|
|
+ return {
|
|
|
+ id: entry.id || `live-${++liveAuditSequence}`,
|
|
|
+ event: entry.event || "unknown_event",
|
|
|
+ details,
|
|
|
+ turn_index: entry.turn_index ?? details.turn_index ?? null,
|
|
|
+ round_index: entry.round_index ?? details.round_index ?? null,
|
|
|
+ turn_elapsed_ms: entry.turn_elapsed_ms ?? details.turn_elapsed_ms ?? null,
|
|
|
+ created_at: entry.created_at || new Date().toISOString(),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function auditEntryKey(entry) {
|
|
|
+ return String(entry.id);
|
|
|
+}
|
|
|
+
|
|
|
+function renderAuditReplay(entries, options = {}) {
|
|
|
+ auditEntries = entries.map(normalizeAuditEntry);
|
|
|
+ auditReplay.textContent = "";
|
|
|
+ auditCount.textContent = `${auditEntries.length} events`;
|
|
|
+
|
|
|
+ if (!auditEntries.length) {
|
|
|
+ selectedAuditKey = null;
|
|
|
+ auditSummaryEl.textContent = "No audit entries";
|
|
|
+ auditReplay.append(emptyReplayRow("No audit entries"));
|
|
|
+ renderAuditDetail(null);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const keys = auditEntries.map(auditEntryKey);
|
|
|
+ selectedAuditKey = options.selectedKey || selectedAuditKey;
|
|
|
+ if (!selectedAuditKey || !keys.includes(selectedAuditKey)) {
|
|
|
+ selectedAuditKey = keys[keys.length - 1];
|
|
|
+ }
|
|
|
+
|
|
|
+ auditEntries.forEach((entry) => {
|
|
|
+ const key = auditEntryKey(entry);
|
|
|
+ const row = document.createElement("button");
|
|
|
+ row.type = "button";
|
|
|
+ row.className = `audit-event ${auditKindClass(entry.event)}`;
|
|
|
+ row.dataset.auditKey = key;
|
|
|
+ if (key === selectedAuditKey) {
|
|
|
+ row.classList.add("is-selected");
|
|
|
+ }
|
|
|
+ row.addEventListener("click", () => {
|
|
|
+ selectAuditEntry(key);
|
|
|
+ });
|
|
|
+
|
|
|
+ const marker = document.createElement("span");
|
|
|
+ marker.className = "audit-marker";
|
|
|
+
|
|
|
+ const body = document.createElement("span");
|
|
|
+ body.className = "audit-event-body";
|
|
|
+
|
|
|
+ const meta = document.createElement("span");
|
|
|
+ meta.className = "audit-event-meta";
|
|
|
+ meta.textContent = auditMeta(entry);
|
|
|
+
|
|
|
+ const title = document.createElement("strong");
|
|
|
+ title.textContent = auditTitle(entry.event);
|
|
|
+
|
|
|
+ const summary = document.createElement("span");
|
|
|
+ summary.className = "audit-event-summary";
|
|
|
+ summary.textContent = auditSummary(entry);
|
|
|
+
|
|
|
+ body.append(meta, title, summary);
|
|
|
+ row.append(marker, body);
|
|
|
+ auditReplay.append(row);
|
|
|
+ });
|
|
|
+
|
|
|
+ renderAuditDetail(
|
|
|
+ auditEntries.find((entry) => auditEntryKey(entry) === selectedAuditKey) || null,
|
|
|
+ );
|
|
|
+ const selectedEntry = auditEntries.find(
|
|
|
+ (entry) => auditEntryKey(entry) === selectedAuditKey,
|
|
|
+ );
|
|
|
+ auditSummaryEl.textContent = selectedEntry
|
|
|
+ ? `${auditTitle(selectedEntry.event)} · ${auditMeta(selectedEntry)}`
|
|
|
+ : `${auditEntries.length} audit events`;
|
|
|
+
|
|
|
+ if (options.scrollToEnd) {
|
|
|
+ auditReplay.scrollTop = auditReplay.scrollHeight;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function selectAuditEntry(key) {
|
|
|
+ selectedAuditKey = key;
|
|
|
+ renderAuditReplay(auditEntries);
|
|
|
+}
|
|
|
+
|
|
|
+function renderAuditDetail(entry) {
|
|
|
+ auditDetail.textContent = "";
|
|
|
+ if (!entry) {
|
|
|
+ auditDetail.append(emptyReplayRow("Select an audit event to inspect details"));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const header = document.createElement("div");
|
|
|
+ header.className = "audit-detail-header";
|
|
|
+ const title = document.createElement("h3");
|
|
|
+ title.className = "audit-detail-title";
|
|
|
+ title.textContent = auditTitle(entry.event);
|
|
|
+ const meta = document.createElement("dl");
|
|
|
+ meta.className = "audit-detail-meta";
|
|
|
+ appendMetaField(meta, "Turn / Round", auditTurnRound(entry));
|
|
|
+ appendMetaField(meta, "Since User Message", auditRelativeTime(entry));
|
|
|
+ appendMetaField(meta, "Time", formatTime(entry.created_at) || "-");
|
|
|
+ appendMetaField(meta, "Agent", auditKindLabel(entry.event));
|
|
|
+ header.append(title, meta);
|
|
|
+ auditDetail.append(header);
|
|
|
+
|
|
|
+ const details = entry.details || {};
|
|
|
+ if (entry.event === "chat_agent_request" || entry.event === "event_agent_request") {
|
|
|
+ appendKeyValueSection(auditDetail, "Model Parameters", details.params);
|
|
|
+ appendMessagesSection(auditDetail, details.messages ? "Messages" : "History", details.messages || details.history);
|
|
|
+ appendEventsSection(auditDetail, details.events);
|
|
|
+ appendToolsSection(auditDetail, details.tools);
|
|
|
+ appendKeyValueSection(auditDetail, "Extra Body", details.extra_body);
|
|
|
+ } else if (entry.event === "chat_agent_response") {
|
|
|
+ appendTextSection(auditDetail, "Assistant Text", details.content);
|
|
|
+ appendListSection(auditDetail, "Events", details.event_names);
|
|
|
+ appendKeyValueSection(auditDetail, "Usage", details.usage);
|
|
|
+ appendRawChunksSection(auditDetail, "Raw LLM Chunks", details.raw_chunks);
|
|
|
+ } else if (entry.event === "event_agent_response") {
|
|
|
+ appendRepliesSection(auditDetail, details.replies);
|
|
|
+ appendRawModelChunksSection(auditDetail, details.raw_model_chunks);
|
|
|
+ } else if (entry.event === "chat_event_detected") {
|
|
|
+ appendKeyValueSection(auditDetail, "Detected Event", {
|
|
|
+ name: details.event_name,
|
|
|
+ id: details.event_id,
|
|
|
+ });
|
|
|
+ } else if (entry.event === "event_agent_completed") {
|
|
|
+ appendListSection(auditDetail, "Completed Events", details.event_names);
|
|
|
+ appendKeyValueSection(auditDetail, "Results", {
|
|
|
+ result_count: details.result_count,
|
|
|
+ });
|
|
|
+ appendTextSection(auditDetail, "Tool Result Summary", details.result_summary);
|
|
|
+ } else if (entry.event === "chat_message_stream_started" || entry.event === "chat_message_stream_finished") {
|
|
|
+ appendKeyValueSection(auditDetail, "Message Stream", {
|
|
|
+ ttft_ms: details.ttft_ms,
|
|
|
+ delta_count: details.delta_count,
|
|
|
+ content_length: details.content_length,
|
|
|
+ });
|
|
|
+ } else if (entry.event === "chat_round_started") {
|
|
|
+ appendKeyValueSection(auditDetail, "Event State", {
|
|
|
+ event_generation_enabled: details.event_generation_enabled,
|
|
|
+ });
|
|
|
+ appendListSection(auditDetail, "Enabled This Round", details.events_enabled);
|
|
|
+ appendListSection(auditDetail, "Configured Events", details.configured_events);
|
|
|
+ } else if (entry.event === "chat_round_finished") {
|
|
|
+ appendKeyValueSection(auditDetail, "Round Result", {
|
|
|
+ had_event: details.had_event,
|
|
|
+ elapsed_ms: details.elapsed_ms,
|
|
|
+ });
|
|
|
+ } else if (entry.event === "turn_completed") {
|
|
|
+ appendKeyValueSection(auditDetail, "Turn Result", {
|
|
|
+ round_count: details.round_count,
|
|
|
+ });
|
|
|
+ } else if (details.raw_chunks) {
|
|
|
+ appendRawChunksSection(auditDetail, "Raw LLM Chunks", details.raw_chunks);
|
|
|
+ } else if (details.raw_model_chunks) {
|
|
|
+ appendRawModelChunksSection(auditDetail, details.raw_model_chunks);
|
|
|
+ } else {
|
|
|
+ appendKeyValueSection(auditDetail, "Details", details);
|
|
|
+ }
|
|
|
+
|
|
|
+ appendRawDetails(auditDetail, details);
|
|
|
+}
|
|
|
+
|
|
|
+function auditTitle(event) {
|
|
|
+ return event
|
|
|
+ .split("_")
|
|
|
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
|
+ .join(" ");
|
|
|
+}
|
|
|
+
|
|
|
+function auditMeta(entry) {
|
|
|
+ const time = formatTime(entry.created_at);
|
|
|
+ const relativeTime = auditRelativeTime(entry);
|
|
|
+ return `${auditTurnRound(entry)} / ${relativeTime}${time ? ` / ${time}` : ""}`;
|
|
|
+}
|
|
|
+
|
|
|
+function auditTurnRound(entry) {
|
|
|
+ const turn = entry.turn_index === null || entry.turn_index === undefined ? "-" : entry.turn_index;
|
|
|
+ const round = entry.round_index === null || entry.round_index === undefined ? "-" : entry.round_index;
|
|
|
+ return `T${turn} / R${round}`;
|
|
|
+}
|
|
|
+
|
|
|
+function auditRelativeTime(entry) {
|
|
|
+ if (entry.turn_elapsed_ms === null || entry.turn_elapsed_ms === undefined) {
|
|
|
+ return "-";
|
|
|
+ }
|
|
|
+ return `+${formatMs(entry.turn_elapsed_ms)}`;
|
|
|
+}
|
|
|
+
|
|
|
+function appendMetaField(container, label, value) {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ const term = document.createElement("dt");
|
|
|
+ term.textContent = label;
|
|
|
+ const description = document.createElement("dd");
|
|
|
+ description.textContent = value;
|
|
|
+ item.append(term, description);
|
|
|
+ container.append(item);
|
|
|
+}
|
|
|
+
|
|
|
+function auditSummary(entry) {
|
|
|
+ const details = entry.details || {};
|
|
|
+ if (entry.event === "chat_agent_request") {
|
|
|
+ return `${details.params?.model || "model default"} · ${details.messages?.length || 0} messages`;
|
|
|
+ }
|
|
|
+ if (entry.event === "event_agent_request") {
|
|
|
+ return `${details.params?.model || "model default"} · ${(details.events || []).map((event) => event.name).join(", ") || "no events"}`;
|
|
|
+ }
|
|
|
+ if (entry.event === "chat_agent_response") {
|
|
|
+ const rawCount = details.raw_chunks?.length || 0;
|
|
|
+ return `${details.usage?.total_tokens || 0} tokens · ${rawCount} raw chunks · ${(details.event_names || []).join(", ") || "no events"}`;
|
|
|
+ }
|
|
|
+ if (entry.event === "event_agent_response") {
|
|
|
+ const rawCount = (details.raw_model_chunks || []).reduce(
|
|
|
+ (count, item) => count + (item.chunks?.length || 0),
|
|
|
+ 0,
|
|
|
+ );
|
|
|
+ return `${details.replies?.length || 0} tool replies · ${rawCount} raw chunks`;
|
|
|
+ }
|
|
|
+ if (entry.event === "chat_event_detected") {
|
|
|
+ return details.event_name || "event detected";
|
|
|
+ }
|
|
|
+ if (entry.event === "event_agent_completed") {
|
|
|
+ return `${details.result_count || 0} results · ${(details.event_names || []).join(", ")}`;
|
|
|
+ }
|
|
|
+ if (entry.event === "chat_message_stream_started") {
|
|
|
+ return `visible text started · TTFT ${formatMs(details.ttft_ms)}`;
|
|
|
+ }
|
|
|
+ if (entry.event === "chat_message_stream_finished") {
|
|
|
+ return `${details.delta_count || 0} deltas · ${details.content_length || 0} chars`;
|
|
|
+ }
|
|
|
+ if (entry.event === "chat_round_started") {
|
|
|
+ const state = details.event_generation_enabled ? "event generation on" : "event generation off";
|
|
|
+ return `${details.events_enabled?.length || 0} enabled this round · ${state}`;
|
|
|
+ }
|
|
|
+ if (entry.event === "chat_round_finished") {
|
|
|
+ return `${formatMs(details.elapsed_ms)} · ${details.had_event ? "event" : "no event"}`;
|
|
|
+ }
|
|
|
+ if (entry.event === "turn_completed") {
|
|
|
+ return `${details.round_count || 0} rounds`;
|
|
|
+ }
|
|
|
+ return JSON.stringify(details).slice(0, 120);
|
|
|
+}
|
|
|
+
|
|
|
+function auditKindClass(event) {
|
|
|
+ if (event.includes("request")) {
|
|
|
+ return "audit-kind-request";
|
|
|
+ }
|
|
|
+ if (event.includes("response") || event.includes("completed")) {
|
|
|
+ return "audit-kind-response";
|
|
|
+ }
|
|
|
+ if (event.includes("event_agent")) {
|
|
|
+ return "audit-kind-event-agent";
|
|
|
+ }
|
|
|
+ if (event.includes("chat")) {
|
|
|
+ return "audit-kind-chat";
|
|
|
+ }
|
|
|
+ return "audit-kind-lifecycle";
|
|
|
+}
|
|
|
+
|
|
|
+function auditKindLabel(event) {
|
|
|
+ if (event.includes("event_agent")) {
|
|
|
+ return "EventAgent";
|
|
|
+ }
|
|
|
+ if (event.includes("chat_agent") || event.includes("chat_round") || event.includes("chat_event") || event.includes("chat_message")) {
|
|
|
+ return "ChatAgent";
|
|
|
+ }
|
|
|
+ return "Lifecycle";
|
|
|
+}
|
|
|
+
|
|
|
+function appendKeyValueSection(container, title, data) {
|
|
|
+ if (!data || !Object.keys(data).length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const grid = document.createElement("div");
|
|
|
+ grid.className = "detail-kv";
|
|
|
+ Object.entries(data).forEach(([key, value]) => {
|
|
|
+ if (value === null || value === undefined || value === "") {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const label = document.createElement("span");
|
|
|
+ label.textContent = key;
|
|
|
+ const content = document.createElement("strong");
|
|
|
+ content.textContent = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
|
+ grid.append(label, content);
|
|
|
+ });
|
|
|
+ if (grid.children.length) {
|
|
|
+ appendDetailSection(container, title, grid);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function appendRawChunksSection(container, title, chunks) {
|
|
|
+ if (!chunks || !chunks.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const list = document.createElement("div");
|
|
|
+ list.className = "detail-list";
|
|
|
+ chunks.forEach((chunk, index) => {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ item.className = "detail-message raw-chunk";
|
|
|
+ const label = document.createElement("span");
|
|
|
+ label.textContent = `chunk ${index + 1}`;
|
|
|
+ const content = document.createElement("pre");
|
|
|
+ content.textContent = JSON.stringify(chunk, null, 2);
|
|
|
+ item.append(label, content);
|
|
|
+ list.append(item);
|
|
|
+ });
|
|
|
+ appendDetailSection(container, title, list);
|
|
|
+}
|
|
|
+
|
|
|
+function appendRawModelChunksSection(container, rawGroups) {
|
|
|
+ if (!rawGroups || !rawGroups.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const list = document.createElement("div");
|
|
|
+ list.className = "detail-list";
|
|
|
+ rawGroups.forEach((group) => {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ item.className = "detail-message raw-chunk";
|
|
|
+ const label = document.createElement("span");
|
|
|
+ label.textContent = `${group.event_name || "event"} · ${group.event_id || "-"}`;
|
|
|
+ const content = document.createElement("pre");
|
|
|
+ content.textContent = JSON.stringify(group.chunks || [], null, 2);
|
|
|
+ item.append(label, content);
|
|
|
+ list.append(item);
|
|
|
+ });
|
|
|
+ appendDetailSection(container, "Raw EventAgent LLM Chunks", list);
|
|
|
+}
|
|
|
+
|
|
|
+function appendMessagesSection(container, title, messages) {
|
|
|
+ if (!messages || !messages.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const list = document.createElement("div");
|
|
|
+ list.className = "detail-list";
|
|
|
+ messages.forEach((message) => {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ item.className = "detail-message";
|
|
|
+ const role = document.createElement("span");
|
|
|
+ role.textContent = message.role || "message";
|
|
|
+ const content = document.createElement("pre");
|
|
|
+ content.textContent = message.content || "";
|
|
|
+ item.append(role, content);
|
|
|
+ list.append(item);
|
|
|
+ });
|
|
|
+ appendDetailSection(container, title, list);
|
|
|
+}
|
|
|
+
|
|
|
+function appendToolsSection(container, tools) {
|
|
|
+ if (!tools || !tools.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const list = document.createElement("div");
|
|
|
+ list.className = "detail-list";
|
|
|
+ tools.forEach((tool) => {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ item.className = "detail-message";
|
|
|
+ const name = document.createElement("span");
|
|
|
+ name.textContent = tool.function?.name || "tool";
|
|
|
+ const description = document.createElement("p");
|
|
|
+ description.textContent = tool.function?.description || "No description";
|
|
|
+ item.append(name, description);
|
|
|
+ list.append(item);
|
|
|
+ });
|
|
|
+ appendDetailSection(container, "Tools", list);
|
|
|
+}
|
|
|
+
|
|
|
+function appendEventsSection(container, events) {
|
|
|
+ if (!events || !events.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const values = events.map((event) => `${event.name}${event.id ? ` (${event.id})` : ""}`);
|
|
|
+ appendListSection(container, "Events", values);
|
|
|
+}
|
|
|
+
|
|
|
+function appendRepliesSection(container, replies) {
|
|
|
+ if (!replies || !replies.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const list = document.createElement("div");
|
|
|
+ list.className = "detail-list";
|
|
|
+ replies.forEach((reply) => {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ item.className = "detail-message";
|
|
|
+ const role = document.createElement("span");
|
|
|
+ role.textContent = reply.role || "reply";
|
|
|
+ const content = document.createElement("pre");
|
|
|
+ content.textContent = reply.content || "";
|
|
|
+ item.append(role, content);
|
|
|
+ list.append(item);
|
|
|
+ });
|
|
|
+ appendDetailSection(container, "Tool Replies", list);
|
|
|
+}
|
|
|
+
|
|
|
+function appendListSection(container, title, items) {
|
|
|
+ if (!items || !items.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const list = document.createElement("ul");
|
|
|
+ list.className = "detail-tags";
|
|
|
+ items.forEach((item) => {
|
|
|
+ const tag = document.createElement("li");
|
|
|
+ const code = document.createElement("code");
|
|
|
+ code.textContent = String(item);
|
|
|
+ tag.append(code);
|
|
|
+ list.append(tag);
|
|
|
+ });
|
|
|
+ appendDetailSection(container, title, list);
|
|
|
+}
|
|
|
+
|
|
|
+function appendTextSection(container, title, value) {
|
|
|
+ if (!value) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const pre = document.createElement("pre");
|
|
|
+ pre.textContent = String(value);
|
|
|
+ appendDetailSection(container, title, pre);
|
|
|
+}
|
|
|
+
|
|
|
+function appendDetailSection(container, title, content) {
|
|
|
+ const section = document.createElement("section");
|
|
|
+ section.className = "audit-detail-block";
|
|
|
+ const heading = document.createElement("h3");
|
|
|
+ heading.className = "audit-detail-block-title";
|
|
|
+ heading.textContent = title;
|
|
|
+ section.append(heading, content);
|
|
|
+ container.append(section);
|
|
|
+}
|
|
|
+
|
|
|
+function appendRawDetails(container, details) {
|
|
|
+ if (!details || !Object.keys(details).length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const raw = document.createElement("details");
|
|
|
+ raw.className = "raw-detail";
|
|
|
+ const summary = document.createElement("summary");
|
|
|
+ summary.textContent = "Raw JSON";
|
|
|
+ const pre = document.createElement("pre");
|
|
|
+ pre.textContent = JSON.stringify(details, null, 2);
|
|
|
+ raw.append(summary, pre);
|
|
|
+ container.append(raw);
|
|
|
+}
|
|
|
+
|
|
|
+function formatTime(value) {
|
|
|
+ if (!value) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ const date = new Date(value);
|
|
|
+ if (Number.isNaN(date.getTime())) {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ return date.toLocaleTimeString([], {
|
|
|
+ hour: "2-digit",
|
|
|
+ minute: "2-digit",
|
|
|
+ second: "2-digit",
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function renderSessionUsage(usage) {
|
|
|
+ const session = usage.session || {};
|
|
|
+ sessionUsageSummary.textContent = "";
|
|
|
+ [
|
|
|
+ ["Tokens", session.total_tokens || 0],
|
|
|
+ ["Cached", session.cached_tokens || 0],
|
|
|
+ ["Prompt", session.prompt_tokens || 0],
|
|
|
+ ["Elapsed", formatMs(session.elapsed_ms || 0)],
|
|
|
+ ].forEach(([label, value]) => {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ const labelEl = document.createElement("span");
|
|
|
+ labelEl.textContent = label;
|
|
|
+ const valueEl = document.createElement("strong");
|
|
|
+ valueEl.textContent = value;
|
|
|
+ item.append(labelEl, valueEl);
|
|
|
+ sessionUsageSummary.append(item);
|
|
|
+ });
|
|
|
+
|
|
|
+ renderUsageRows(
|
|
|
+ sessionUsageTurns,
|
|
|
+ usage.turns || [],
|
|
|
+ (turn) => `Turn ${turn.turn_index}: ${turn.total_tokens} tokens, ${turn.elapsed_ms}ms`,
|
|
|
+ );
|
|
|
+ renderUsageRows(
|
|
|
+ sessionUsageCalls,
|
|
|
+ usage.calls || [],
|
|
|
+ (call) => `T${call.turn_index} R${call.round_index}: ${call.total_tokens} tokens, TTFT ${formatMs(call.ttft_ms)}, elapsed ${formatMs(call.elapsed_ms)}`,
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function renderUsageRows(container, rows, formatter) {
|
|
|
+ container.textContent = "";
|
|
|
+ if (!rows.length) {
|
|
|
+ container.append(emptyReplayRow("No usage rows"));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ rows.forEach((row) => {
|
|
|
+ const item = document.createElement("div");
|
|
|
+ item.className = "replay-row";
|
|
|
+ item.textContent = formatter(row);
|
|
|
+ container.append(item);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function emptyReplayRow(content) {
|
|
|
+ const row = document.createElement("div");
|
|
|
+ row.className = "replay-row";
|
|
|
+ row.textContent = content;
|
|
|
+ return row;
|
|
|
+}
|
|
|
+
|
|
|
function updateUsage(usage) {
|
|
|
if (hasBackendRoundStats) {
|
|
|
return;
|
|
|
@@ -829,6 +1549,7 @@ function stopElapsedTimer() {
|
|
|
}
|
|
|
|
|
|
function setRunState(isRunning) {
|
|
|
+ turnActive = isRunning;
|
|
|
runButton.disabled = isRunning;
|
|
|
}
|
|
|
|
|
|
@@ -850,8 +1571,12 @@ function resetTurnStats() {
|
|
|
}
|
|
|
|
|
|
function closeSocket() {
|
|
|
- if (socket && socket.readyState < WebSocket.CLOSING) {
|
|
|
- socket.close();
|
|
|
+ const closingSocket = socket;
|
|
|
+ if (closingSocket && closingSocket.readyState < WebSocket.CLOSING) {
|
|
|
+ closingSocket.close();
|
|
|
+ }
|
|
|
+ if (socket === closingSocket) {
|
|
|
+ socket = null;
|
|
|
}
|
|
|
}
|
|
|
|