pi-trace-viewer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/README.zh.md +151 -0
- package/assets/images/compaction-context-viewport.png +0 -0
- package/assets/images/pi-export-viewport.png +0 -0
- package/assets/images/realtime-session-viewport.png +0 -0
- package/package.json +62 -0
- package/src/collector.ts +218 -0
- package/src/index.ts +145 -0
- package/src/security.ts +20 -0
- package/src/server.ts +244 -0
- package/src/store.ts +200 -0
- package/src/types.ts +126 -0
- package/tsconfig.json +14 -0
- package/web/app.js +541 -0
- package/web/index.html +78 -0
- package/web/render-helpers.js +276 -0
- package/web/styles.css +318 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import * as R from "./render-helpers.js";
|
|
2
|
+
(() => {
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const state = {
|
|
6
|
+
mode: "session",
|
|
7
|
+
sessions: [],
|
|
8
|
+
sessionId: new URLSearchParams(location.search).get("session"),
|
|
9
|
+
session: null,
|
|
10
|
+
calls: [],
|
|
11
|
+
callId: new URLSearchParams(location.search).get("call"),
|
|
12
|
+
leafId: new URLSearchParams(location.search).get("entry"),
|
|
13
|
+
filter: "default",
|
|
14
|
+
callFilter: "all",
|
|
15
|
+
search: "",
|
|
16
|
+
detailTab: "compare",
|
|
17
|
+
contextView: "pi",
|
|
18
|
+
pendingTargetScroll: false,
|
|
19
|
+
refreshTimer: null,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const elements = {
|
|
23
|
+
picker: document.querySelector("#session-picker"),
|
|
24
|
+
live: document.querySelector("#live-status"),
|
|
25
|
+
search: document.querySelector("#tree-search"),
|
|
26
|
+
tree: document.querySelector("#tree-container"),
|
|
27
|
+
treeStatus: document.querySelector("#tree-status"),
|
|
28
|
+
header: document.querySelector("#header-container"),
|
|
29
|
+
messages: document.querySelector("#messages"),
|
|
30
|
+
sessionView: document.querySelector("#session-view"),
|
|
31
|
+
callsView: document.querySelector("#calls-view"),
|
|
32
|
+
callDetail: document.querySelector("#call-detail"),
|
|
33
|
+
empty: document.querySelector("#empty-state"),
|
|
34
|
+
sessionFilters: document.querySelector("#session-filters"),
|
|
35
|
+
callFilters: document.querySelector("#call-filters"),
|
|
36
|
+
modal: document.querySelector("#image-modal"),
|
|
37
|
+
modalImage: document.querySelector("#modal-image"),
|
|
38
|
+
toast: document.querySelector("#toast"),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
globalThis.marked?.setOptions({ gfm: true, breaks: false });
|
|
42
|
+
bindControls();
|
|
43
|
+
connectEvents();
|
|
44
|
+
refreshAll();
|
|
45
|
+
|
|
46
|
+
function bindControls() {
|
|
47
|
+
document.querySelectorAll(".mode-btn").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.mode)));
|
|
48
|
+
document.querySelectorAll(".filter-btn").forEach((button) => button.addEventListener("click", () => {
|
|
49
|
+
state.filter = button.dataset.filter;
|
|
50
|
+
document.querySelectorAll(".filter-btn").forEach((item) => item.classList.toggle("active", item === button));
|
|
51
|
+
renderSessionTree();
|
|
52
|
+
}));
|
|
53
|
+
document.querySelectorAll(".call-filter").forEach((button) => button.addEventListener("click", () => {
|
|
54
|
+
state.callFilter = button.dataset.callFilter;
|
|
55
|
+
document.querySelectorAll(".call-filter").forEach((item) => item.classList.toggle("active", item === button));
|
|
56
|
+
renderCallTree();
|
|
57
|
+
renderCallDetail();
|
|
58
|
+
}));
|
|
59
|
+
elements.picker.addEventListener("change", () => selectSession(elements.picker.value));
|
|
60
|
+
elements.search.addEventListener("input", () => {
|
|
61
|
+
state.search = elements.search.value.trim().toLowerCase();
|
|
62
|
+
state.mode === "session" ? renderSessionTree() : renderCallTree();
|
|
63
|
+
});
|
|
64
|
+
document.querySelector("#refresh-btn").addEventListener("click", async () => {
|
|
65
|
+
await fetch("/api/refresh", { method: "POST" });
|
|
66
|
+
await refreshAll();
|
|
67
|
+
toast("Data refreshed");
|
|
68
|
+
});
|
|
69
|
+
document.querySelector("#sidebar-toggle").addEventListener("click", () => toggleSidebar(true));
|
|
70
|
+
document.querySelector("#sidebar-overlay").addEventListener("click", () => toggleSidebar(false));
|
|
71
|
+
elements.modal.addEventListener("click", (event) => {
|
|
72
|
+
if (event.target === elements.modal || event.target.closest(".modal-close")) closeImage();
|
|
73
|
+
});
|
|
74
|
+
document.addEventListener("keydown", (event) => {
|
|
75
|
+
if (event.key === "Escape") {
|
|
76
|
+
closeImage();
|
|
77
|
+
toggleSidebar(false);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
bindResizer();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bindResizer() {
|
|
84
|
+
const resizer = document.querySelector("#sidebar-resizer");
|
|
85
|
+
let resizing = false;
|
|
86
|
+
resizer.addEventListener("mousedown", () => { resizing = true; document.body.style.userSelect = "none"; });
|
|
87
|
+
window.addEventListener("mousemove", (event) => {
|
|
88
|
+
if (!resizing) return;
|
|
89
|
+
document.documentElement.style.setProperty("--sidebar-width", `${Math.max(240, Math.min(840, event.clientX))}px`);
|
|
90
|
+
});
|
|
91
|
+
window.addEventListener("mouseup", () => { resizing = false; document.body.style.userSelect = ""; });
|
|
92
|
+
resizer.addEventListener("keydown", (event) => {
|
|
93
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
94
|
+
const current = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue("--sidebar-width"), 10);
|
|
95
|
+
const next = Math.max(240, Math.min(840, current + (event.key === "ArrowRight" ? 20 : -20)));
|
|
96
|
+
document.documentElement.style.setProperty("--sidebar-width", `${next}px`);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function connectEvents() {
|
|
101
|
+
const source = new EventSource("/api/events");
|
|
102
|
+
source.addEventListener("ready", () => setLive("live", "live"));
|
|
103
|
+
source.onopen = () => setLive("live", "live");
|
|
104
|
+
source.onerror = () => setLive("offline", "reconnecting");
|
|
105
|
+
["trace-record", "session-added", "session-updated", "session-detached", "refresh"].forEach((name) => {
|
|
106
|
+
source.addEventListener(name, () => scheduleRefresh());
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scheduleRefresh() {
|
|
111
|
+
clearTimeout(state.refreshTimer);
|
|
112
|
+
state.refreshTimer = setTimeout(refreshAll, 120);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function refreshAll() {
|
|
116
|
+
try {
|
|
117
|
+
state.sessions = await fetchJson("/api/sessions");
|
|
118
|
+
if (!state.sessionId || !state.sessions.some((session) => session.id === state.sessionId)) {
|
|
119
|
+
state.sessionId = state.sessions.find((session) => session.active)?.id || state.sessions[0]?.id || null;
|
|
120
|
+
}
|
|
121
|
+
renderSessionPicker();
|
|
122
|
+
if (!state.sessionId) {
|
|
123
|
+
state.session = null;
|
|
124
|
+
state.calls = [];
|
|
125
|
+
render();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const encoded = encodeURIComponent(state.sessionId);
|
|
129
|
+
[state.session, state.calls] = await Promise.all([
|
|
130
|
+
fetchJson(`/api/sessions/${encoded}`),
|
|
131
|
+
fetchJson(`/api/sessions/${encoded}/calls`),
|
|
132
|
+
]);
|
|
133
|
+
if (!state.callId || !state.calls.some((call) => call.callId === state.callId)) state.callId = state.calls[0]?.callId || null;
|
|
134
|
+
if (!state.leafId) state.leafId = state.session.leafId;
|
|
135
|
+
syncUrl();
|
|
136
|
+
render();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
setLive("offline", "offline");
|
|
139
|
+
toast(error instanceof Error ? error.message : String(error));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function selectSession(sessionId) {
|
|
144
|
+
state.sessionId = sessionId;
|
|
145
|
+
state.leafId = null;
|
|
146
|
+
state.callId = null;
|
|
147
|
+
await refreshAll();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function setMode(mode) {
|
|
151
|
+
state.mode = mode === "calls" ? "calls" : "session";
|
|
152
|
+
document.querySelectorAll(".mode-btn").forEach((button) => button.classList.toggle("active", button.dataset.mode === state.mode));
|
|
153
|
+
elements.sessionFilters.classList.toggle("hidden", state.mode !== "session");
|
|
154
|
+
elements.callFilters.classList.toggle("hidden", state.mode !== "calls");
|
|
155
|
+
elements.search.placeholder = state.mode === "session" ? "Search..." : "Search calls...";
|
|
156
|
+
syncUrl();
|
|
157
|
+
render();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function render() {
|
|
161
|
+
const empty = !state.session;
|
|
162
|
+
elements.empty.classList.toggle("hidden", !empty);
|
|
163
|
+
elements.sessionView.classList.toggle("hidden", empty || state.mode !== "session");
|
|
164
|
+
elements.callsView.classList.toggle("hidden", empty || state.mode !== "calls");
|
|
165
|
+
if (empty) {
|
|
166
|
+
elements.tree.innerHTML = "";
|
|
167
|
+
elements.treeStatus.textContent = "Waiting for a pi session";
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (state.mode === "session") {
|
|
171
|
+
renderSessionTree();
|
|
172
|
+
renderSessionContent();
|
|
173
|
+
} else {
|
|
174
|
+
renderCallTree();
|
|
175
|
+
renderCallDetail();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderSessionPicker() {
|
|
180
|
+
elements.picker.innerHTML = state.sessions.map((session) => {
|
|
181
|
+
const label = session.name || shortId(session.id);
|
|
182
|
+
return `<option value="${escapeAttr(session.id)}"${session.id === state.sessionId ? " selected" : ""}>${escapeHtml(label)}${session.active ? " • live" : ""}</option>`;
|
|
183
|
+
}).join("");
|
|
184
|
+
elements.picker.disabled = state.sessions.length === 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderSessionTree() {
|
|
188
|
+
if (!state.session) return;
|
|
189
|
+
const entries = state.session.entries || [];
|
|
190
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
191
|
+
const branch = new Set(pathToRoot(byId, state.leafId || state.session.leafId).map((entry) => entry.id));
|
|
192
|
+
const visible = entries.filter((entry) => passesSessionFilter(entry) && matchesSessionSearch(entry));
|
|
193
|
+
const toolCalls = buildToolCallMap(entries);
|
|
194
|
+
const layout = layoutVisibleTree(entries, visible);
|
|
195
|
+
elements.tree.innerHTML = layout.map((item) => {
|
|
196
|
+
const entry = item.entry;
|
|
197
|
+
const active = entry.id === (state.leafId || state.session.leafId);
|
|
198
|
+
const descriptor = R.describeEntry(entry, toolCalls);
|
|
199
|
+
const prefix = buildTreePrefix(item);
|
|
200
|
+
return `<div class="tree-row ${descriptor.className}${active ? " active" : ""}${branch.has(entry.id) ? " on-branch" : ""}" data-entry-id="${escapeAttr(entry.id)}" role="treeitem" tabindex="0" aria-selected="${active}">
|
|
201
|
+
<span class="tree-prefix">${escapeHtml(prefix)}</span><span class="tree-icon">${branch.has(entry.id) ? "•" : " "}</span><span class="tree-lines">${descriptor.lines.map((line) => `<span class="tree-line ${line.className}">${line.label ? `<span class="tree-line-label">${escapeHtml(line.label)}</span>` : ""}${escapeHtml(line.text)}</span>`).join("")}</span>
|
|
202
|
+
</div>`;
|
|
203
|
+
}).join("");
|
|
204
|
+
elements.tree.querySelectorAll("[data-entry-id]").forEach((row) => {
|
|
205
|
+
const activate = () => {
|
|
206
|
+
state.leafId = row.dataset.entryId;
|
|
207
|
+
state.pendingTargetScroll = true;
|
|
208
|
+
syncUrl();
|
|
209
|
+
renderSessionTree();
|
|
210
|
+
renderSessionContent();
|
|
211
|
+
toggleSidebar(false);
|
|
212
|
+
};
|
|
213
|
+
row.addEventListener("click", activate);
|
|
214
|
+
row.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") activate(); });
|
|
215
|
+
});
|
|
216
|
+
elements.treeStatus.textContent = `${visible.length} of ${entries.length} entries · leaf ${shortId(state.leafId || state.session.leafId || "root")}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function passesSessionFilter(entry) {
|
|
220
|
+
if (state.filter === "all") return true;
|
|
221
|
+
if (state.filter === "labeled-only") return Boolean(entry.label);
|
|
222
|
+
if (state.filter === "user-only") return entry.type === "message" && entry.message?.role === "user";
|
|
223
|
+
if (state.filter === "no-tools") return !isSetting(entry) && !(entry.type === "message" && entry.message?.role === "toolResult");
|
|
224
|
+
return !isSetting(entry);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isSetting(entry) {
|
|
228
|
+
return ["label", "model_change", "thinking_level_change", "session_info"].includes(entry.type);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function matchesSessionSearch(entry) {
|
|
232
|
+
if (!state.search) return true;
|
|
233
|
+
return JSON.stringify(entry).toLowerCase().includes(state.search);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderSessionContent() {
|
|
237
|
+
const session = state.session;
|
|
238
|
+
const entries = session.entries || [];
|
|
239
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
240
|
+
const branchEntries = pathToRoot(byId, state.leafId || session.leafId);
|
|
241
|
+
const stats = sessionStats(entries);
|
|
242
|
+
elements.header.innerHTML = `<header class="session-header">
|
|
243
|
+
<div class="session-title-row">
|
|
244
|
+
<h1 class="session-title">${escapeHtml(session.name || "Session Export")}</h1>
|
|
245
|
+
<span class="session-badge ${session.active ? "active" : ""}">${session.active ? "LIVE" : "HISTORICAL"}</span>
|
|
246
|
+
<div class="header-actions"><a class="small-btn" href="/api/sessions/${encodeURIComponent(session.id)}/download">↓ JSONL</a></div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="info-grid">
|
|
249
|
+
<div><span class="info-label">Session:</span><span class="info-value">${escapeHtml(session.id)}</span></div>
|
|
250
|
+
<div><span class="info-label">Created:</span><span class="info-value">${formatTime(session.header?.timestamp)}</span></div>
|
|
251
|
+
<div><span class="info-label">Working directory:</span><span class="info-value">${escapeHtml(session.cwd)}</span></div>
|
|
252
|
+
<div><span class="info-label">Messages:</span><span class="info-value">${stats.user} user · ${stats.assistant} assistant · ${stats.tools} tools</span></div>
|
|
253
|
+
<div><span class="info-label">Tokens:</span><span class="info-value">${number(stats.tokens)}</span></div>
|
|
254
|
+
<div><span class="info-label">Cost:</span><span class="info-value">$${stats.cost.toFixed(4)}</span></div>
|
|
255
|
+
<div><span class="info-label">Models:</span><span class="info-value">${escapeHtml([...stats.models].join(", ") || "unknown")}</span></div>
|
|
256
|
+
<div><span class="info-label">Trace calls:</span><span class="info-value">${state.calls.length}</span></div>
|
|
257
|
+
<div><span class="info-label">Trace storage:</span><span class="info-value ${session.tracePersistence?.status === "memory_only" ? "trace-warning" : ""}">${escapeHtml(formatTracePersistence(session.tracePersistence))}</span></div>
|
|
258
|
+
</div>
|
|
259
|
+
${session.systemPrompt ? `<details class="header-disclosure"><summary>System prompt</summary><pre>${escapeHtml(session.systemPrompt)}</pre></details>` : ""}
|
|
260
|
+
${session.tools?.length ? `<details class="header-disclosure"><summary>Tools (${session.tools.length})</summary><pre>${escapeHtml(JSON.stringify(session.tools, null, 2))}</pre></details>` : ""}
|
|
261
|
+
</header>`;
|
|
262
|
+
elements.messages.innerHTML = branchEntries.map((entry) => R.renderEntry(entry, entries)).join("");
|
|
263
|
+
bindRenderedContent();
|
|
264
|
+
if (state.pendingTargetScroll) {
|
|
265
|
+
state.pendingTargetScroll = false;
|
|
266
|
+
const targetId = `entry-${state.leafId || ""}`;
|
|
267
|
+
requestAnimationFrame(() => {
|
|
268
|
+
const target = document.getElementById(targetId);
|
|
269
|
+
target?.classList.add("highlight");
|
|
270
|
+
target?.scrollIntoView({ block: "end", behavior: "smooth" });
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderCallTree() {
|
|
276
|
+
const calls = filteredCalls();
|
|
277
|
+
if (calls.length && !calls.some((call) => call.callId === state.callId)) {
|
|
278
|
+
state.callId = calls[0].callId;
|
|
279
|
+
state.detailTab = "compare";
|
|
280
|
+
syncUrl();
|
|
281
|
+
}
|
|
282
|
+
const sequence = new Map([...state.calls].sort((a, b) => a.startedAt.localeCompare(b.startedAt)).map((call, index) => [call.callId, index + 1]));
|
|
283
|
+
elements.tree.innerHTML = calls.map((call) => {
|
|
284
|
+
const active = call.callId === state.callId;
|
|
285
|
+
const identity = callIdentity(call, sequence.get(call.callId));
|
|
286
|
+
return `<div class="tree-row call-tree-row call-${escapeAttr(call.kind)}${active ? " active" : ""}" data-call-id="${escapeAttr(call.callId)}" role="treeitem" tabindex="0" aria-selected="${active}">
|
|
287
|
+
<span class="call-status ${call.status}" aria-hidden="true"></span><span class="sr-only">${escapeHtml(call.status)}</span><span class="call-tree-content"><span class="call-tree-title">${escapeHtml(identity.title)}</span><span class="call-tree-trigger">${escapeHtml(identity.trigger)}</span>${identity.activity ? `<span class="call-tree-activity">${escapeHtml(identity.activity)}</span>` : ""}</span><span class="call-meta"><span>${formatClock(call.startedAt)}</span><span>${escapeHtml(identity.duration)}</span></span>
|
|
288
|
+
</div>`;
|
|
289
|
+
}).join("");
|
|
290
|
+
elements.tree.querySelectorAll("[data-call-id]").forEach((row) => {
|
|
291
|
+
const activate = () => {
|
|
292
|
+
state.callId = row.dataset.callId;
|
|
293
|
+
state.detailTab = "compare";
|
|
294
|
+
syncUrl();
|
|
295
|
+
renderCallTree();
|
|
296
|
+
renderCallDetail();
|
|
297
|
+
toggleSidebar(false);
|
|
298
|
+
};
|
|
299
|
+
row.addEventListener("click", activate);
|
|
300
|
+
row.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") activate(); });
|
|
301
|
+
});
|
|
302
|
+
elements.treeStatus.textContent = `${calls.length} of ${state.calls.length} LLM calls`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function filteredCalls() {
|
|
306
|
+
return state.calls.filter((call) => {
|
|
307
|
+
if (state.callFilter === "agent" && call.kind !== "agent") return false;
|
|
308
|
+
if (state.callFilter === "summary" && !["compaction", "branch_summary"].includes(call.kind)) return false;
|
|
309
|
+
if (state.callFilter === "error" && call.status !== "error") return false;
|
|
310
|
+
if (!state.search) return true;
|
|
311
|
+
return JSON.stringify(call).toLowerCase().includes(state.search);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function renderCallDetail() {
|
|
316
|
+
const call = state.calls.find((candidate) => candidate.callId === state.callId);
|
|
317
|
+
if (!call) {
|
|
318
|
+
elements.callDetail.innerHTML = `<div class="empty-state"><div class="empty-glyph">⌁</div><h1>No LLM calls yet</h1><p>The first context event will appear here in real time.</p></div>`;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const duration = call.completedAt ? Math.max(0, new Date(call.completedAt) - new Date(call.startedAt)) : null;
|
|
322
|
+
const tabs = [
|
|
323
|
+
["overview", "Overview"], ["compare", "Context"], ["stream", `Output Stream (${call.outputEvents.length})`], ["final", "Final Output"],
|
|
324
|
+
];
|
|
325
|
+
elements.callDetail.innerHTML = `<header class="call-header">
|
|
326
|
+
<div class="call-header-top"><h1 class="call-title">${escapeHtml(call.kind.replace("_", " "))}</h1><span class="status-pill ${call.status}">${call.status}</span>${call.captureSource === "session_entry" ? `<span class="source-pill">restored from session entry</span>` : ""}<span class="call-id">${escapeHtml(shortId(call.callId))}</span></div>
|
|
327
|
+
<div class="call-summary-grid">
|
|
328
|
+
<div><span class="info-label">Model:</span>${escapeHtml(call.model ? `${call.model.provider}/${call.model.id}` : "unknown")}</div>
|
|
329
|
+
<div><span class="info-label">API:</span>${escapeHtml(call.model?.api || "unknown")}</div>
|
|
330
|
+
<div><span class="info-label">Turn:</span>${call.turnIndex ?? "—"}</div>
|
|
331
|
+
<div><span class="info-label">Started:</span>${formatTime(call.startedAt)}</div>
|
|
332
|
+
<div><span class="info-label">Duration:</span>${duration === null ? "running" : `${duration} ms`}</div>
|
|
333
|
+
<div><span class="info-label">Requests:</span>${call.providerRequests.length}</div>
|
|
334
|
+
</div>
|
|
335
|
+
</header>
|
|
336
|
+
<nav class="detail-tabs" aria-label="Call detail">${tabs.map(([id, label]) => `<button class="detail-tab${state.detailTab === id ? " active" : ""}" data-detail-tab="${id}">${escapeHtml(label)}</button>`).join("")}</nav>
|
|
337
|
+
<div class="detail-panel">${renderDetailPanel(call)}</div>`;
|
|
338
|
+
elements.callDetail.querySelectorAll("[data-detail-tab]").forEach((button) => button.addEventListener("click", () => {
|
|
339
|
+
state.detailTab = button.dataset.detailTab;
|
|
340
|
+
renderCallDetail();
|
|
341
|
+
}));
|
|
342
|
+
elements.callDetail.querySelectorAll("[data-context-view]").forEach((button) => button.addEventListener("click", () => {
|
|
343
|
+
state.contextView = button.dataset.contextView;
|
|
344
|
+
renderCallDetail();
|
|
345
|
+
}));
|
|
346
|
+
bindRenderedContent();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderDetailPanel(call) {
|
|
350
|
+
if (state.detailTab === "overview") {
|
|
351
|
+
return `<div class="compare-grid">
|
|
352
|
+
<section class="data-panel"><div class="panel-heading">Lifecycle</div><pre class="json-block">${json({
|
|
353
|
+
callId: call.callId, kind: call.kind, status: call.status, startedAt: call.startedAt, completedAt: call.completedAt,
|
|
354
|
+
turnIndex: call.turnIndex, leafId: call.leafId, model: call.model, captureSource: call.captureSource, sourceEntryId: call.sourceEntryId, error: call.error,
|
|
355
|
+
})}</pre></section>
|
|
356
|
+
<section class="data-panel"><div class="panel-heading">Provider responses</div><pre class="json-block">${json(call.providerResponses)}</pre></section>
|
|
357
|
+
</div>`;
|
|
358
|
+
}
|
|
359
|
+
if (state.detailTab === "stream") {
|
|
360
|
+
if (!call.outputEvents.length) return unavailable(call.kind === "agent" ? "No streaming events were captured." : "Pi does not expose normalized streaming events for internal summary calls.");
|
|
361
|
+
return `<div class="event-list">${call.outputEvents.map((item, index) => `<article class="stream-event"><div class="stream-event-head"><span>#${index + 1}</span><span class="stream-event-type">${escapeHtml(item.event.type)}</span><span>${formatClock(item.timestamp)}</span></div><pre>${json(item.event)}</pre></article>`).join("")}</div>`;
|
|
362
|
+
}
|
|
363
|
+
if (state.detailTab === "final") {
|
|
364
|
+
if (!call.finalMessage) return unavailable(call.status === "running" ? "The model is still producing output." : "No final normalized message was captured.");
|
|
365
|
+
return `<section class="data-panel"><div class="panel-heading">Normalized AssistantMessage <button class="small-btn" data-copy-json="final">Copy JSON</button></div><div class="assistant-message">${R.renderContent(call.finalMessage.content, "assistant")}</div><details class="header-disclosure"><summary>Raw JSON</summary><pre class="json-block" data-json-source="final">${json(call.finalMessage)}</pre></details></section>`;
|
|
366
|
+
}
|
|
367
|
+
const request = call.providerRequests.at(-1);
|
|
368
|
+
const piSource = call.context || call.compactionContext;
|
|
369
|
+
const piPanel = `<section class="data-panel context-single-panel"><div class="panel-heading">Pi Context <span class="panel-subtitle">${call.compactionContext && !call.context ? "compaction preparation" : "normalized"}</span>${piSource ? ` <button class="small-btn" data-copy-json="context">Copy JSON</button>` : ""}</div>${call.context ? R.renderPiContext(call.context) : call.compactionContext ? R.renderCompactionContext(call.compactionContext) : unavailableHtml(call.captureSource === "session_entry" ? "This historical compact call predates trace capture; its original compaction preparation was not stored." : "Not exposed for this internal or unmatched provider call.")}${piSource ? `<details class="raw-json-disclosure"><summary>Raw JSON</summary><pre class="json-block" data-json-source="context">${json(piSource)}</pre></details>` : ""}</section>`;
|
|
370
|
+
const providerPanel = `<section class="data-panel context-single-panel"><div class="panel-heading">Provider Payload <span class="panel-subtitle">${escapeHtml(call.model?.api || "backend-specific")}${request ? ` · attempt ${request.attempt}` : ""}</span>${request ? ` <button class="small-btn" data-copy-json="payload">Copy JSON</button>` : ""}</div>${request ? `${R.renderProviderPayload(request.payload)}<details class="raw-json-disclosure"><summary>Raw JSON</summary><pre class="json-block" data-json-source="payload">${json(request.payload)}</pre></details>` : unavailableHtml(call.captureSource === "session_entry" ? "The provider request predates trace capture and cannot be reconstructed from the session file." : "Provider payload has not been emitted yet.")}</section>`;
|
|
371
|
+
return `<div class="context-view-switch" role="group" aria-label="Context representation"><button class="context-view-btn${state.contextView === "pi" ? " active" : ""}" data-context-view="pi" aria-pressed="${state.contextView === "pi"}">Pi Context</button><button class="context-view-btn${state.contextView === "provider" ? " active" : ""}" data-context-view="provider" aria-pressed="${state.contextView === "provider"}">Provider Payload</button></div>${state.contextView === "provider" ? providerPanel : piPanel}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function bindRenderedContent() {
|
|
375
|
+
document.querySelectorAll("pre code").forEach((block) => globalThis.hljs?.highlightElement(block));
|
|
376
|
+
document.querySelectorAll(".message-image").forEach((image) => image.addEventListener("click", () => openImage(image.src)));
|
|
377
|
+
document.querySelectorAll("[data-copy-link]").forEach((button) => button.addEventListener("click", () => {
|
|
378
|
+
const url = new URL(location.href);
|
|
379
|
+
url.searchParams.set("entry", button.dataset.copyLink);
|
|
380
|
+
navigator.clipboard.writeText(url.toString());
|
|
381
|
+
toast("Link copied");
|
|
382
|
+
}));
|
|
383
|
+
document.querySelectorAll("[data-copy-json]").forEach((button) => button.addEventListener("click", () => {
|
|
384
|
+
const source = document.querySelector(`[data-json-source="${button.dataset.copyJson}"]`);
|
|
385
|
+
navigator.clipboard.writeText(source?.textContent || "");
|
|
386
|
+
toast("JSON copied");
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function pathToRoot(byId, leafId) {
|
|
391
|
+
const path = [];
|
|
392
|
+
let current = leafId ? byId.get(leafId) : null;
|
|
393
|
+
if (!current && byId.size) current = [...byId.values()].at(-1);
|
|
394
|
+
const seen = new Set();
|
|
395
|
+
while (current && !seen.has(current.id)) {
|
|
396
|
+
path.push(current);
|
|
397
|
+
seen.add(current.id);
|
|
398
|
+
current = current.parentId ? byId.get(current.parentId) : null;
|
|
399
|
+
}
|
|
400
|
+
return path.reverse();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function layoutVisibleTree(entries, visibleEntries) {
|
|
404
|
+
const allById = new Map(entries.map((entry) => [entry.id, entry]));
|
|
405
|
+
const visibleIds = new Set(visibleEntries.map((entry) => entry.id));
|
|
406
|
+
const children = new Map([[null, []]]);
|
|
407
|
+
const order = new Map(entries.map((entry, index) => [entry.id, index]));
|
|
408
|
+
for (const entry of visibleEntries) {
|
|
409
|
+
let parentId = entry.parentId;
|
|
410
|
+
const seen = new Set();
|
|
411
|
+
while (parentId && !visibleIds.has(parentId) && !seen.has(parentId)) {
|
|
412
|
+
seen.add(parentId);
|
|
413
|
+
parentId = allById.get(parentId)?.parentId;
|
|
414
|
+
}
|
|
415
|
+
const visibleParent = visibleIds.has(parentId) ? parentId : null;
|
|
416
|
+
if (!children.has(visibleParent)) children.set(visibleParent, []);
|
|
417
|
+
children.get(visibleParent).push(entry.id);
|
|
418
|
+
}
|
|
419
|
+
for (const ids of children.values()) ids.sort((a, b) => (order.get(a) || 0) - (order.get(b) || 0));
|
|
420
|
+
const roots = children.get(null) || [];
|
|
421
|
+
const multipleRoots = roots.length > 1;
|
|
422
|
+
const stack = [];
|
|
423
|
+
const orderedRoots = roots;
|
|
424
|
+
for (let index = orderedRoots.length - 1; index >= 0; index -= 1) stack.push({ id: orderedRoots[index], indent: multipleRoots ? 1 : 0, justBranched: multipleRoots, showConnector: multipleRoots, isLast: index === orderedRoots.length - 1, gutters: [], isVirtualRootChild: multipleRoots, multipleRoots });
|
|
425
|
+
const result = [];
|
|
426
|
+
while (stack.length) {
|
|
427
|
+
const item = stack.pop();
|
|
428
|
+
result.push({ ...item, entry: allById.get(item.id) });
|
|
429
|
+
const orderedChildren = children.get(item.id) || [];
|
|
430
|
+
const multipleChildren = orderedChildren.length > 1;
|
|
431
|
+
const childIndent = multipleChildren || (item.justBranched && item.indent > 0) ? item.indent + 1 : item.indent;
|
|
432
|
+
const displayIndent = item.multipleRoots ? Math.max(0, item.indent - 1) : item.indent;
|
|
433
|
+
const childGutters = item.showConnector && !item.isVirtualRootChild ? [...item.gutters, { position: Math.max(0, displayIndent - 1), show: !item.isLast }] : item.gutters;
|
|
434
|
+
for (let index = orderedChildren.length - 1; index >= 0; index -= 1) stack.push({ id: orderedChildren[index], indent: childIndent, justBranched: multipleChildren, showConnector: multipleChildren, isLast: index === orderedChildren.length - 1, gutters: childGutters, isVirtualRootChild: false, multipleRoots: item.multipleRoots });
|
|
435
|
+
}
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function buildTreePrefix(item) {
|
|
440
|
+
const displayIndent = item.multipleRoots ? Math.max(0, item.indent - 1) : item.indent;
|
|
441
|
+
const connector = item.showConnector && !item.isVirtualRootChild;
|
|
442
|
+
let prefix = "";
|
|
443
|
+
for (let level = 0; level < displayIndent; level += 1) {
|
|
444
|
+
const gutter = item.gutters.find((candidate) => candidate.position === level);
|
|
445
|
+
if (gutter) prefix += gutter.show ? "│ " : " ";
|
|
446
|
+
else if (connector && level === displayIndent - 1) prefix += item.isLast ? "└─ " : "├─ ";
|
|
447
|
+
else prefix += " ";
|
|
448
|
+
}
|
|
449
|
+
return prefix;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function buildToolCallMap(entries) {
|
|
453
|
+
const calls = new Map();
|
|
454
|
+
for (const entry of entries) {
|
|
455
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
|
|
456
|
+
for (const block of entry.message.content) if (block?.type === "toolCall" && block.id) calls.set(block.id, { name: block.name, arguments: block.arguments || {} });
|
|
457
|
+
}
|
|
458
|
+
return calls;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function callIdentity(call, sequence) {
|
|
462
|
+
const kind = call.kind === "agent" ? "Agent" : call.kind === "compaction" ? "Compact" : call.kind === "branch_summary" ? "Branch summary" : "Unknown";
|
|
463
|
+
const model = call.model?.id || "unknown model";
|
|
464
|
+
const turn = call.turnIndex === undefined ? "—" : call.turnIndex;
|
|
465
|
+
const messages = call.context?.messages || [];
|
|
466
|
+
const userMessage = [...messages].reverse().find((message) => message?.role === "user");
|
|
467
|
+
const triggerText = R.contentText(userMessage?.content) || (call.kind === "compaction" ? "Compress current context" : call.kind === "branch_summary" ? "Summarize abandoned branch" : "Continuation after tool result");
|
|
468
|
+
const finalContent = Array.isArray(call.finalMessage?.content) ? call.finalMessage.content : [];
|
|
469
|
+
const toolNames = finalContent.filter((block) => block?.type === "toolCall").map((block) => block.name).filter(Boolean);
|
|
470
|
+
const output = finalContent.filter((block) => block?.type === "text").map((block) => block.text).join(" ").replace(/\s+/g, " ").trim();
|
|
471
|
+
const activity = call.captureSource === "session_entry" ? `restored summary: ${output}` : toolNames.length ? `tools: ${toolNames.join(", ")}` : output ? `output: ${output}` : call.error ? `error: ${call.error}` : call.status === "running" ? "streaming…" : "no normalized output";
|
|
472
|
+
const durationMs = call.completedAt ? Math.max(0, new Date(call.completedAt) - new Date(call.startedAt)) : null;
|
|
473
|
+
return {
|
|
474
|
+
title: `#${sequence || "?"} · ${kind} · Turn ${turn} · ${model}`,
|
|
475
|
+
trigger: `prompt: ${truncate(triggerText, 150)}`,
|
|
476
|
+
activity: truncate(activity, 150),
|
|
477
|
+
duration: durationMs === null ? "running" : durationMs < 1000 ? `${durationMs} ms` : `${(durationMs / 1000).toFixed(1)} s`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function sessionStats(entries) {
|
|
482
|
+
const result = { user: 0, assistant: 0, tools: 0, tokens: 0, cost: 0, models: new Set() };
|
|
483
|
+
for (const entry of entries) {
|
|
484
|
+
if (entry.type !== "message") continue;
|
|
485
|
+
const message = entry.message || {};
|
|
486
|
+
if (message.role === "user") result.user += 1;
|
|
487
|
+
else if (message.role === "assistant") {
|
|
488
|
+
result.assistant += 1;
|
|
489
|
+
result.tokens += message.usage?.totalTokens || 0;
|
|
490
|
+
result.cost += message.usage?.cost?.total || 0;
|
|
491
|
+
if (message.model) result.models.add(message.provider ? `${message.provider}/${message.model}` : message.model);
|
|
492
|
+
} else if (message.role === "toolResult") result.tools += 1;
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function unavailable(message) {
|
|
498
|
+
return `<div class="data-panel">${unavailableHtml(message)}</div>`;
|
|
499
|
+
}
|
|
500
|
+
function unavailableHtml(message) {
|
|
501
|
+
return `<div class="unavailable"><strong>Unavailable at extension level</strong>${escapeHtml(message)}</div>`;
|
|
502
|
+
}
|
|
503
|
+
function json(value) { return escapeHtml(JSON.stringify(value, null, 2) ?? "null"); }
|
|
504
|
+
function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); }
|
|
505
|
+
function escapeAttr(value) { return escapeHtml(value); }
|
|
506
|
+
function number(value) { return new Intl.NumberFormat("en-US").format(value || 0); }
|
|
507
|
+
function shortId(value) { return String(value || "").slice(0, 8); }
|
|
508
|
+
function truncate(value, length) { const text = String(value || "").replace(/\s+/g, " ").trim(); return text.length > length ? `${text.slice(0, length)}…` : text; }
|
|
509
|
+
function formatTime(value) { return value ? new Date(value).toLocaleString() : "unknown"; }
|
|
510
|
+
function formatClock(value) { return value ? new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : ""; }
|
|
511
|
+
function formatTracePersistence(value) {
|
|
512
|
+
if (!value) return "unknown";
|
|
513
|
+
if (value.status === "persisted") return value.filePath || "persisted";
|
|
514
|
+
return value.error ? `memory only · ${value.error}` : "memory only";
|
|
515
|
+
}
|
|
516
|
+
async function fetchJson(url) {
|
|
517
|
+
const response = await fetch(url, { cache: "no-store" });
|
|
518
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
519
|
+
return response.json();
|
|
520
|
+
}
|
|
521
|
+
function setLive(className, label) { elements.live.className = `live-status ${className}`; elements.live.innerHTML = `<span class="status-dot"></span>${escapeHtml(label)}`; }
|
|
522
|
+
function toast(message) {
|
|
523
|
+
elements.toast.textContent = message;
|
|
524
|
+
elements.toast.classList.add("show");
|
|
525
|
+
clearTimeout(toast.timer);
|
|
526
|
+
toast.timer = setTimeout(() => elements.toast.classList.remove("show"), 1800);
|
|
527
|
+
}
|
|
528
|
+
function openImage(src) { elements.modalImage.src = src; elements.modal.classList.add("open"); elements.modal.focus(); }
|
|
529
|
+
function closeImage() { elements.modal.classList.remove("open"); elements.modalImage.src = ""; }
|
|
530
|
+
function toggleSidebar(open) { document.body.classList.toggle("sidebar-open", open); document.querySelector("#sidebar-toggle").setAttribute("aria-expanded", String(open)); }
|
|
531
|
+
function syncUrl() {
|
|
532
|
+
const url = new URL(location.href);
|
|
533
|
+
state.sessionId ? url.searchParams.set("session", state.sessionId) : url.searchParams.delete("session");
|
|
534
|
+
state.mode === "calls" ? url.searchParams.set("mode", "calls") : url.searchParams.delete("mode");
|
|
535
|
+
state.callId && state.mode === "calls" ? url.searchParams.set("call", state.callId) : url.searchParams.delete("call");
|
|
536
|
+
state.leafId && state.mode === "session" ? url.searchParams.set("entry", state.leafId) : url.searchParams.delete("entry");
|
|
537
|
+
history.replaceState(null, "", url);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (new URLSearchParams(location.search).get("mode") === "calls") setMode("calls");
|
|
541
|
+
})();
|
package/web/index.html
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Pi Trace Viewer</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<a class="skip-link" href="#content">Skip to content</a>
|
|
11
|
+
<header class="topbar">
|
|
12
|
+
<div class="brand"><span class="brand-mark" aria-hidden="true"></span> PI TRACE</div>
|
|
13
|
+
<nav class="mode-switch" aria-label="Viewer mode">
|
|
14
|
+
<button class="mode-btn active" data-mode="session">Session</button>
|
|
15
|
+
<button class="mode-btn" data-mode="calls">LLM Calls</button>
|
|
16
|
+
</nav>
|
|
17
|
+
<div class="topbar-spacer"></div>
|
|
18
|
+
<label class="session-picker-label" for="session-picker">Session</label>
|
|
19
|
+
<select id="session-picker" aria-label="Select session"></select>
|
|
20
|
+
<span id="live-status" class="live-status" role="status" aria-live="polite"><span class="status-dot"></span>connecting</span>
|
|
21
|
+
<button id="refresh-btn" class="icon-btn" title="Refresh data" aria-label="Refresh data">
|
|
22
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.34 5.66M20 4v7h-7"/></svg>
|
|
23
|
+
</button>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<div id="app">
|
|
27
|
+
<button id="sidebar-toggle" class="sidebar-toggle" aria-label="Open sidebar" aria-expanded="false">
|
|
28
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
|
29
|
+
</button>
|
|
30
|
+
<div id="sidebar-overlay"></div>
|
|
31
|
+
<aside id="sidebar">
|
|
32
|
+
<div class="sidebar-header">
|
|
33
|
+
<input id="tree-search" class="sidebar-search" type="search" placeholder="Search..." aria-label="Search session">
|
|
34
|
+
<div id="session-filters" class="sidebar-filters">
|
|
35
|
+
<button class="filter-btn active" data-filter="default">Default</button>
|
|
36
|
+
<button class="filter-btn" data-filter="no-tools">No-tools</button>
|
|
37
|
+
<button class="filter-btn" data-filter="user-only">User</button>
|
|
38
|
+
<button class="filter-btn" data-filter="labeled-only">Labeled</button>
|
|
39
|
+
<button class="filter-btn" data-filter="all">All</button>
|
|
40
|
+
</div>
|
|
41
|
+
<div id="call-filters" class="sidebar-filters hidden">
|
|
42
|
+
<button class="call-filter active" data-call-filter="all">All</button>
|
|
43
|
+
<button class="call-filter" data-call-filter="agent">Agent</button>
|
|
44
|
+
<button class="call-filter" data-call-filter="summary">Summaries</button>
|
|
45
|
+
<button class="call-filter" data-call-filter="error">Errors</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div id="tree-container" class="tree-container" role="tree"></div>
|
|
49
|
+
<div id="tree-status" class="tree-status"></div>
|
|
50
|
+
</aside>
|
|
51
|
+
<div id="sidebar-resizer" role="separator" aria-orientation="vertical" aria-label="Resize sidebar" tabindex="0"></div>
|
|
52
|
+
<main id="content" tabindex="-1">
|
|
53
|
+
<div id="empty-state" class="empty-state hidden">
|
|
54
|
+
<div class="empty-glyph">⌁</div>
|
|
55
|
+
<h1>No session selected</h1>
|
|
56
|
+
<p>Start pi with this extension enabled, then return here.</p>
|
|
57
|
+
</div>
|
|
58
|
+
<section id="session-view">
|
|
59
|
+
<div id="header-container"></div>
|
|
60
|
+
<div id="messages"></div>
|
|
61
|
+
</section>
|
|
62
|
+
<section id="calls-view" class="hidden">
|
|
63
|
+
<div id="call-detail"></div>
|
|
64
|
+
</section>
|
|
65
|
+
</main>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div id="image-modal" class="image-modal" role="dialog" aria-modal="true" aria-label="Image preview" tabindex="-1">
|
|
69
|
+
<button class="modal-close" aria-label="Close image preview">×</button>
|
|
70
|
+
<img id="modal-image" src="" alt="Expanded message attachment">
|
|
71
|
+
</div>
|
|
72
|
+
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
|
73
|
+
|
|
74
|
+
<script src="/vendor/marked.js"></script>
|
|
75
|
+
<script src="/vendor/highlight.js"></script>
|
|
76
|
+
<script type="module" src="/app.js"></script>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|