|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>LangChain JSONL Trace Viewer</title> |
|
<style> |
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
|
|
|
:root { |
|
--bg: #0f1117; |
|
--surface: #161b27; |
|
--surface2: #1c2130; |
|
--border: #2a3045; |
|
--border-sub: #212840; |
|
--text: #e2e8f0; |
|
--text-sec: #7c8ba1; |
|
--text-dim: #4a5568; |
|
--parent-bg: rgba(99,132,186,0.10); |
|
--parent-bdr: rgba(99,132,186,0.45); |
|
--sub-bg: rgba(186,130,90,0.10); |
|
--sub-bdr: rgba(186,130,90,0.45); |
|
--pill-info: #1e3a5f; |
|
--pill-info-t: #63a3d4; |
|
--pill-warn: #3d2a0e; |
|
--pill-warn-t: #d4943b; |
|
--pill-ok: #0e3023; |
|
--pill-ok-t: #3db87a; |
|
--pill-err: #3a1020; |
|
--pill-err-t: #d45f7c; |
|
--accent: #4f83cc; |
|
--code-bg: #1e2438; |
|
--code-t: #9db8e0; |
|
--mono: 'Fira Mono', 'Cascadia Code', 'Consolas', monospace; |
|
--sans: system-ui, -apple-system, sans-serif; |
|
--meta-w: 340px; |
|
} |
|
|
|
body { |
|
font-family: var(--sans); |
|
background: var(--bg); |
|
color: var(--text); |
|
min-height: 100vh; |
|
font-size: 13px; |
|
line-height: 1.5; |
|
} |
|
|
|
/* ── Drop zone ─────────────────────────────────────────────────────────── */ |
|
#drop-zone { |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
justify-content: center; |
|
gap: 14px; |
|
min-height: 100vh; |
|
padding: 40px; |
|
text-align: center; |
|
} |
|
#drop-zone.dragging { |
|
background: rgba(79,131,204,0.06); |
|
outline: 2px dashed var(--accent); |
|
outline-offset: -16px; |
|
} |
|
#drop-zone h1 { font-size: 20px; font-weight: 600; color: var(--text); } |
|
#drop-zone p { color: var(--text-sec); font-size: 13px; max-width: 420px; } |
|
|
|
.file-btn { |
|
display: inline-block; |
|
padding: 9px 20px; |
|
background: var(--accent); |
|
color: #fff; |
|
border-radius: 5px; |
|
cursor: pointer; |
|
font-size: 13px; |
|
font-weight: 500; |
|
transition: opacity .15s; |
|
border: none; |
|
} |
|
.file-btn:hover { opacity: .85; } |
|
#file-input { display: none; } |
|
|
|
/* ── Main layout ───────────────────────────────────────────────────────── */ |
|
#app { display: none; } |
|
#app.visible { display: flex; flex-direction: column; height: 100vh; overflow: hidden; } |
|
|
|
.page-wrap { |
|
max-width: 100%; |
|
padding: 20px 40px 0; |
|
display: flex; |
|
flex-direction: column; |
|
flex: 1; |
|
min-height: 0; |
|
} |
|
.page-section { max-width: 1120px; flex-shrink: 0; } |
|
|
|
/* ── Typography ─────────────────────────────────────────────────────────── */ |
|
h1.page-title { font-size: 18px; font-weight: 600; margin-bottom: 2px; } |
|
h2.section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text); } |
|
.subtitle { color: var(--text-sec); font-size: 12px; } |
|
|
|
/* ── Stats row ──────────────────────────────────────────────────────────── */ |
|
.stats-row { display: flex; gap: 24px; margin: 20px 0; flex-wrap: wrap; } |
|
.stat { min-width: 90px; } |
|
.stat-value { font-size: 22px; font-weight: 600; color: var(--text); line-height: 1.1; } |
|
.stat-label { font-size: 11px; color: var(--text-sec); margin-top: 2px; text-transform: uppercase; letter-spacing: .04em; } |
|
|
|
/* ── Divider ────────────────────────────────────────────────────────────── */ |
|
hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; } |
|
|
|
/* ── Observations ───────────────────────────────────────────────────────── */ |
|
.obs-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 24px; } |
|
.obs-item { |
|
background: var(--surface); border: 1px solid var(--border); border-radius: 6px; |
|
padding: 12px 14px; display: flex; gap: 10px; align-items: flex-start; |
|
} |
|
.obs-body { font-size: 12px; color: var(--text-sec); line-height: 1.6; flex: 1; } |
|
|
|
/* ── Pills ──────────────────────────────────────────────────────────────── */ |
|
.pill { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 11px; font-weight: 500; white-space: nowrap; line-height: 1.5; } |
|
.pill-info { background: var(--pill-info); color: var(--pill-info-t); } |
|
.pill-warn { background: var(--pill-warn); color: var(--pill-warn-t); } |
|
.pill-ok { background: var(--pill-ok); color: var(--pill-ok-t); } |
|
.pill-err { background: var(--pill-err); color: var(--pill-err-t); } |
|
.pill-neutral { background: var(--surface2); color: var(--text-sec); } |
|
|
|
/* ── Code spans ─────────────────────────────────────────────────────────── */ |
|
code { |
|
font-family: var(--mono); background: var(--code-bg); color: var(--code-t); |
|
padding: 1px 5px; border-radius: 3px; font-size: 11px; white-space: nowrap; |
|
} |
|
|
|
/* ── Inventory table ────────────────────────────────────────────────────── */ |
|
.inventory-table { width: 100%; border-collapse: collapse; font-size: 12px; } |
|
.inventory-table th { |
|
text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); |
|
font-size: 11px; font-weight: 600; color: var(--text-sec); text-transform: uppercase; letter-spacing: .04em; |
|
} |
|
.inventory-table td { padding: 5px 10px; border-bottom: 1px solid var(--border-sub); vertical-align: middle; } |
|
.inventory-table tr.parent-row { background: var(--parent-bg); } |
|
.inventory-table tr.subagent-row { background: var(--sub-bg); } |
|
.inventory-table tr:last-child td { border-bottom: none; } |
|
.mono { font-family: var(--mono); color: var(--text-sec); font-size: 11px; } |
|
.mono-dim { font-family: var(--mono); color: var(--text-dim); font-size: 11px; } |
|
|
|
/* ── Tool params ────────────────────────────────────────────────────────── */ |
|
.tool-params { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; } |
|
.tool-param { |
|
font-family: var(--mono); font-size: 10px; color: var(--text-sec); |
|
background: var(--surface2); border: 1px solid var(--border-sub); border-radius: 3px; |
|
padding: 1px 5px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
|
} |
|
.tool-param-key { color: var(--text-dim); } |
|
.tool-param-val { color: var(--code-t); } |
|
|
|
/* ── Recent files ───────────────────────────────────────────────────────── */ |
|
#recent-section { margin-top: 28px; width: 100%; max-width: 480px; } |
|
#recent-section h2 { |
|
font-size: 11px; font-weight: 600; color: var(--text-dim); |
|
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 8px; |
|
} |
|
.recent-list { display: flex; flex-direction: column; gap: 4px; } |
|
.recent-item { |
|
display: flex; align-items: center; gap: 10px; padding: 8px 12px; |
|
background: var(--surface); border: 1px solid var(--border); border-radius: 5px; |
|
cursor: pointer; transition: border-color .12s; |
|
} |
|
.recent-item:hover { border-color: var(--accent); } |
|
.recent-name { font-size: 13px; color: var(--text); flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|
.recent-meta { font-size: 11px; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; } |
|
.recent-del { font-size: 14px; color: var(--text-dim); background: none; border: none; cursor: pointer; padding: 0 2px; line-height: 1; flex-shrink: 0; } |
|
.recent-del:hover { color: var(--pill-err-t); } |
|
|
|
/* back button */ |
|
#back-btn { display: inline-block; margin-bottom: 14px; font-size: 12px; color: var(--text-sec); cursor: pointer; background: none; border: none; padding: 0; } |
|
#back-btn:hover { color: var(--accent); } |
|
|
|
.copy-btn { |
|
display: inline-flex; align-items: center; gap: 3px; font-size: 10px; font-family: var(--sans); |
|
color: var(--text-dim); background: none; border: 1px solid var(--border); border-radius: 3px; |
|
padding: 1px 5px; cursor: pointer; transition: color .12s, border-color .12s; white-space: nowrap; |
|
} |
|
.copy-btn:hover { color: var(--accent); border-color: var(--accent); } |
|
.copy-btn.copied { color: var(--pill-ok-t); border-color: var(--pill-ok-t); } |
|
|
|
/* ── Error message ──────────────────────────────────────────────────────── */ |
|
.error-box { |
|
background: var(--pill-err); border: 1px solid var(--pill-err-t); |
|
border-radius: 6px; padding: 14px 18px; color: var(--pill-err-t); font-size: 13px; margin-top: 16px; |
|
} |
|
|
|
/* ════════════════════════════════════════════════════════════════════════════ |
|
FLAT LINE LIST — primary view |
|
Each .fl-row = one JSONL line. Left gutter (line-no) + right JSON cell. |
|
When a group is "active", the metadata panel overlays to the left. |
|
════════════════════════════════════════════════════════════════════════════ */ |
|
|
|
/* Outer container: metadata panel sits to the left of the line list */ |
|
.lines-view { |
|
display: flex; |
|
align-items: stretch; |
|
} |
|
|
|
/* ── Metadata panel (left, hidden until a group is activated) ──────────── */ |
|
.meta-panel { |
|
flex: 0 0 var(--meta-w); |
|
width: var(--meta-w); |
|
display: none; /* hidden by default */ |
|
flex-direction: column; |
|
border-right: 2px solid var(--border); |
|
background: var(--surface); |
|
align-self: stretch; |
|
} |
|
.meta-panel.visible { display: flex; } |
|
|
|
.meta-group { |
|
padding: 10px 12px; |
|
border-bottom: 1px solid var(--border-sub); |
|
border-left: 3px solid transparent; |
|
cursor: pointer; |
|
transition: background .1s; |
|
} |
|
.meta-group:last-child { border-bottom: none; } |
|
.meta-group.parent { border-left-color: var(--parent-bdr); } |
|
.meta-group.subagent { border-left-color: var(--sub-bdr); } |
|
.meta-group:hover { filter: brightness(1.1); } |
|
.meta-group.active.parent { background: var(--parent-bg); } |
|
.meta-group.active.subagent { background: var(--sub-bg); } |
|
|
|
.meta-group-header { |
|
display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; |
|
} |
|
.meta-lines { font-family: var(--mono); font-size: 10px; color: var(--text-dim); } |
|
.meta-runid { font-family: var(--mono); font-size: 10px; color: var(--text-sec); word-break: break-all; margin: 3px 0; } |
|
.meta-tools { margin-top: 4px; display: flex; flex-direction: column; gap: 3px; } |
|
.meta-tool { font-family: var(--mono); font-size: 10px; color: var(--text-sec); } |
|
.meta-tool-result { font-size: 10px; color: var(--text-dim); } |
|
.meta-snip { font-family: var(--mono); font-size: 10px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
|
|
|
/* ── Line list ─────────────────────────────────────────────────────────── */ |
|
.line-list { |
|
flex: 1; |
|
min-width: 0; |
|
display: flex; |
|
flex-direction: column; |
|
} |
|
|
|
.fl-row { |
|
display: grid; |
|
grid-template-columns: 46px 1fr; |
|
border-bottom: 1px solid var(--border-sub); |
|
font-family: var(--mono); |
|
font-size: 11px; |
|
line-height: 1.55; |
|
min-height: fit-content; |
|
cursor: pointer; |
|
} |
|
.fl-row:last-child { border-bottom: none; } |
|
|
|
/* When a group is active, lines inside it lose their border and get a background */ |
|
.fl-row.group-active { |
|
border-bottom-color: transparent; |
|
} |
|
.fl-row.group-active.parent { background: var(--parent-bg); } |
|
.fl-row.group-active.subagent { background: var(--sub-bg); } |
|
/* Restore bottom border only on the last line of an active group */ |
|
.fl-row.group-active.group-last { |
|
border-bottom: 1px solid var(--border-sub); |
|
} |
|
|
|
.fl-lineno { |
|
padding: 4px 6px; |
|
color: var(--text-dim); |
|
text-align: right; |
|
border-right: 1px solid var(--border-sub); |
|
user-select: none; |
|
flex-shrink: 0; |
|
} |
|
|
|
.fl-json { |
|
padding: 4px 8px; |
|
color: var(--text-sec); |
|
overflow: hidden; |
|
white-space: nowrap; |
|
text-overflow: ellipsis; |
|
height: fit-content; |
|
} |
|
.fl-json:hover { background: var(--surface2); } |
|
.fl-json.pretty { |
|
white-space: pre-wrap; |
|
word-break: break-all; |
|
background: var(--surface2); |
|
} |
|
|
|
/* ── Lines section title bar ────────────────────────────────────────────── */ |
|
.lines-bar { |
|
display: flex; |
|
align-items: center; |
|
gap: 10px; |
|
flex-shrink: 0; |
|
padding: 10px 0; |
|
flex-wrap: wrap; |
|
} |
|
.lines-bar h2 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); } |
|
.lines-bar .hint { font-size: 11px; color: var(--text-dim); font-weight: 400; flex: 1; } |
|
|
|
.bar-btn { |
|
font-size: 11px; |
|
font-family: var(--sans); |
|
color: var(--accent); |
|
background: none; |
|
border: 1px solid var(--border); |
|
border-radius: 4px; |
|
padding: 4px 12px; |
|
cursor: pointer; |
|
transition: border-color .12s; |
|
white-space: nowrap; |
|
} |
|
.bar-btn:hover { border-color: var(--accent); } |
|
|
|
/* ── Lines view fills remaining viewport height ─────────────────────────── */ |
|
.lines-view { |
|
flex: 1; |
|
min-height: 0; |
|
overflow: hidden; |
|
} |
|
.meta-panel { overflow-y: auto; height: 100%; } |
|
.line-list { overflow-y: auto; height: 100%; } |
|
|
|
/* ── Metadata modal ─────────────────────────────────────────────────────── */ |
|
.modal-backdrop { |
|
display: none; |
|
position: fixed; |
|
inset: 0; |
|
background: rgba(0,0,0,.65); |
|
z-index: 100; |
|
align-items: center; |
|
justify-content: center; |
|
padding: 32px; |
|
} |
|
.modal-backdrop.open { display: flex; } |
|
|
|
.modal { |
|
background: var(--surface); |
|
border: 1px solid var(--border); |
|
border-radius: 8px; |
|
width: 100%; |
|
max-width: 820px; |
|
max-height: 80vh; |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
} |
|
.modal-header { |
|
display: flex; |
|
align-items: center; |
|
padding: 14px 18px; |
|
border-bottom: 1px solid var(--border); |
|
gap: 10px; |
|
} |
|
.modal-title { font-size: 14px; font-weight: 600; flex: 1; } |
|
.modal-close { |
|
font-size: 18px; |
|
line-height: 1; |
|
color: var(--text-dim); |
|
background: none; |
|
border: none; |
|
cursor: pointer; |
|
padding: 2px 6px; |
|
} |
|
.modal-close:hover { color: var(--text); } |
|
.modal-body { |
|
overflow-y: auto; |
|
padding: 18px; |
|
flex: 1; |
|
display: flex; |
|
flex-direction: column; |
|
gap: 20px; |
|
} |
|
|
|
.modal-stats { display: flex; gap: 20px; flex-wrap: wrap; } |
|
.modal-obs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } |
|
|
|
.modal-inventory { width: 100%; border-collapse: collapse; font-size: 12px; } |
|
.modal-inventory th { |
|
text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); |
|
font-size: 11px; font-weight: 600; color: var(--text-sec); text-transform: uppercase; letter-spacing: .04em; |
|
} |
|
.modal-inventory td { padding: 5px 10px; border-bottom: 1px solid var(--border-sub); vertical-align: middle; } |
|
.modal-inventory tr.parent-row { background: var(--parent-bg); } |
|
.modal-inventory tr.subagent-row { background: var(--sub-bg); } |
|
.modal-inventory tr:last-child td { border-bottom: none; } |
|
|
|
/* ── Responsive ─────────────────────────────────────────────────────────── */ |
|
@media (max-width: 700px) { |
|
:root { --meta-w: 220px; } |
|
.obs-grid { grid-template-columns: 1fr; } |
|
.modal-obs { grid-template-columns: 1fr; } |
|
.modal-backdrop { padding: 12px; } |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<!-- ── Drop / pick zone ────────────────────────────────────────────────────── --> |
|
<div id="drop-zone"> |
|
<h1>LangChain JSONL Trace Viewer</h1> |
|
<p>Drop a <code>.jsonl</code> LangChain stream file here, or pick one from disk.</p> |
|
<label class="file-btn"> |
|
Open file |
|
<input type="file" id="file-input" accept=".jsonl,.json,.txt"> |
|
</label> |
|
<p style="font-size:11px; color:var(--text-dim);"> |
|
Works entirely in your browser — nothing is uploaded anywhere. |
|
</p> |
|
<div id="recent-section" style="display:none"> |
|
<h2>Recent files</h2> |
|
<div class="recent-list" id="recent-list"></div> |
|
</div> |
|
</div> |
|
|
|
<!-- ── Main app view ───────────────────────────────────────────────────────── --> |
|
<div id="app"> |
|
<div class="page-wrap"> |
|
<div class="page-section"> |
|
<h1 class="page-title">LangChain JSONL — turn & tool call map</h1> |
|
<div class="subtitle" id="file-info"></div> |
|
</div> |
|
|
|
<div class="lines-bar"> |
|
<h2>Lines</h2> |
|
<span class="hint">— click any line to reveal its group metadata</span> |
|
<button class="bar-btn" onclick="showDropZone()">← open file</button> |
|
<button class="bar-btn" onclick="showMetadataModal()">show metadata</button> |
|
</div> |
|
|
|
<div class="lines-view"> |
|
<div class="meta-panel" id="meta-panel"></div> |
|
<div class="line-list" id="line-list"></div> |
|
</div> |
|
|
|
<div id="error-area"></div> |
|
</div> |
|
</div> |
|
|
|
<!-- ── Metadata modal ──────────────────────────────────────────────────────── --> |
|
<div class="modal-backdrop" id="modal-backdrop" onclick="closeMetadataModal(event)"> |
|
<div class="modal"> |
|
<div class="modal-header"> |
|
<span class="modal-title">File metadata</span> |
|
<button class="modal-close" onclick="closeMetadataModal()">×</button> |
|
</div> |
|
<div class="modal-body" id="modal-body"></div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// Recent files — stored in localStorage |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
const RECENT_KEY = 'trace-viewer-recent'; |
|
const SELECTED_KEY = 'trace-viewer-selected'; |
|
const RECENT_MAX = 5; |
|
const CONTENT_LIMIT = 4 * 1024 * 1024; |
|
|
|
function loadRecent() { |
|
try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } |
|
catch (_) { return []; } |
|
} |
|
function saveRecent(list) { |
|
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch (_) {} |
|
} |
|
function addToRecent(name, size, content) { |
|
let list = loadRecent().filter(r => r.name !== name); |
|
const entry = { name, size, openedAt: Date.now() }; |
|
if (size <= CONTENT_LIMIT) entry.content = content; |
|
list.unshift(entry); |
|
list = list.slice(0, RECENT_MAX); |
|
saveRecent(list); |
|
renderRecentList(); |
|
} |
|
function removeFromRecent(name) { |
|
saveRecent(loadRecent().filter(r => r.name !== name)); |
|
renderRecentList(); |
|
} |
|
function formatSize(bytes) { |
|
if (bytes < 1024) return bytes + ' B'; |
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; |
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB'; |
|
} |
|
function formatAge(ts) { |
|
const s = Math.floor((Date.now() - ts) / 1000); |
|
if (s < 60) return 'just now'; |
|
if (s < 3600) return Math.floor(s / 60) + 'm ago'; |
|
if (s < 86400) return Math.floor(s / 3600) + 'h ago'; |
|
return Math.floor(s / 86400) + 'd ago'; |
|
} |
|
function renderRecentList() { |
|
const list = loadRecent(); |
|
const section = document.getElementById('recent-section'); |
|
const ul = document.getElementById('recent-list'); |
|
if (list.length === 0) { section.style.display = 'none'; return; } |
|
section.style.display = ''; |
|
ul.innerHTML = list.map(r => { |
|
const hasContent = !!r.content; |
|
const itemAttrs = hasContent |
|
? `onclick="openFromRecent('${CSS.escape(r.name)}')" title="Click to re-open"` |
|
: `style="opacity:.5;cursor:default" title="Content too large to cache"`; |
|
return ` |
|
<div class="recent-item" ${itemAttrs}> |
|
<span class="recent-name">${esc(r.name)}</span> |
|
<span class="recent-meta">${formatSize(r.size)} · ${formatAge(r.openedAt)}</span> |
|
<button class="recent-del" title="Remove" onclick="event.stopPropagation(); removeFromRecent('${CSS.escape(r.name)}')">×</button> |
|
</div>`; |
|
}).join(''); |
|
} |
|
function openFromRecent(name) { |
|
const entry = loadRecent().find(r => r.name === name); |
|
if (entry && entry.content) parseAndRender(entry.content, entry.name); |
|
} |
|
function saveSelected(name) { try { localStorage.setItem(SELECTED_KEY, name); } catch (_) {} } |
|
function clearSelected() { try { localStorage.removeItem(SELECTED_KEY); } catch (_) {} } |
|
function loadSelected() { try { return localStorage.getItem(SELECTED_KEY) || null; } catch (_) { return null; } } |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// File input / drag-drop |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
const dropZone = document.getElementById('drop-zone'); |
|
const fileInput = document.getElementById('file-input'); |
|
const appEl = document.getElementById('app'); |
|
|
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragging'); }); |
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragging')); |
|
dropZone.addEventListener('drop', e => { |
|
e.preventDefault(); dropZone.classList.remove('dragging'); |
|
const file = e.dataTransfer.files[0]; |
|
if (file) readFile(file); |
|
}); |
|
fileInput.addEventListener('change', () => { if (fileInput.files[0]) readFile(fileInput.files[0]); }); |
|
|
|
function readFile(file) { |
|
const reader = new FileReader(); |
|
reader.onload = e => { addToRecent(file.name, file.size, e.target.result); parseAndRender(e.target.result, file.name); }; |
|
reader.readAsText(file); |
|
} |
|
function showDropZone() { |
|
clearSelected(); |
|
appEl.classList.remove('visible'); |
|
dropZone.style.display = ''; |
|
renderRecentList(); |
|
} |
|
|
|
renderRecentList(); |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// JSONL parser → turn model |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function parseJSONL(text) { |
|
const lines = text.split('\n').filter(l => l.trim()); |
|
const parsed = []; |
|
for (let i = 0; i < lines.length; i++) { |
|
try { parsed.push({ lineNo: i + 1, data: JSON.parse(lines[i]), raw: lines[i] }); } |
|
catch (_) { /* skip malformed */ } |
|
} |
|
return parsed; |
|
} |
|
|
|
function buildTurns(entries) { |
|
const turnsMap = new Map(); |
|
const turnOrder = []; |
|
|
|
for (const { lineNo, data, raw } of entries) { |
|
const runId = data.id || ''; |
|
const shortId = runId.replace('lc_run--', ''); |
|
|
|
if (!turnsMap.has(shortId)) { |
|
turnsMap.set(shortId, { |
|
runId: shortId, kind: 'parent', |
|
startLine: lineNo, endLine: lineNo, aiLastLine: lineNo, |
|
stopReason: null, inputTokens: 0, outputTokens: 0, |
|
textBefore: false, text: '', |
|
toolCalls: [], toolResults: [], entries: [], _tcAccum: {}, |
|
}); |
|
turnOrder.push(shortId); |
|
} |
|
|
|
const turn = turnsMap.get(shortId); |
|
turn.endLine = lineNo; |
|
turn.entries.push({ lineNo, data, raw }); |
|
|
|
const type = data.type; |
|
|
|
if (type === 'AIMessageChunk') { |
|
for (const chunk of (data.tool_call_chunks || [])) { |
|
if (chunk.name && chunk.id) { |
|
turn.toolCalls.push({ line: lineNo, name: chunk.name, toolCallId: chunk.id, index: chunk.index ?? 0, pattern: null, _argsRaw: '' }); |
|
turn._tcAccum[chunk.id] = ''; |
|
} |
|
} |
|
for (const chunk of (data.tool_call_chunks || [])) { |
|
if (!chunk.name && chunk.args && chunk.index != null) { |
|
const tc = [...turn.toolCalls].reverse().find(t => t.index === chunk.index); |
|
if (tc) tc._argsRaw = (tc._argsRaw || '') + chunk.args; |
|
} |
|
} |
|
const u = data.usage_metadata; |
|
if (u) { |
|
if (u.input_tokens) turn.inputTokens = u.input_tokens; |
|
if (u.output_tokens) turn.outputTokens = u.output_tokens; |
|
} |
|
if (data.chunk_position === 'last') { |
|
turn.aiLastLine = lineNo; |
|
const rm = data.response_metadata || {}; |
|
turn.stopReason = rm.stop_reason || rm.finish_reason || 'unknown'; |
|
} |
|
for (const item of (data.content || [])) { |
|
if (item.type === 'text' && item.text) { |
|
turn.text += item.text; |
|
if (turn.toolCalls.length === 0) turn.textBefore = true; |
|
} |
|
} |
|
} |
|
|
|
if (type === 'tool') { |
|
const resultText = typeof data.content === 'string' ? data.content : JSON.stringify(data.content).slice(0, 200); |
|
turn.toolResults.push({ line: lineNo, name: data.name || '?', toolCallId: data.tool_call_id || '', status: data.status || 'success', snippet: resultText.slice(0, 160) }); |
|
turn.endLine = lineNo; |
|
} |
|
} |
|
|
|
let inSubagentBlock = false; |
|
let parentTaskRunId = null; |
|
|
|
for (const id of turnOrder) { |
|
const turn = turnsMap.get(id); |
|
for (const tc of turn.toolCalls) { |
|
if (tc._argsRaw) { |
|
try { |
|
const args = JSON.parse(tc._argsRaw); |
|
tc.args = args; |
|
if (tc.name === 'grep' && args.pattern) tc.pattern = args.pattern; |
|
if (tc.name === 'task' && args.subagent_type) tc.subagentType = args.subagent_type; |
|
if (tc.name === 'read_file'&& args.path) tc.path = args.path; |
|
} catch (_) {} |
|
} |
|
} |
|
const callsTask = turn.toolCalls.some(tc => tc.name === 'task'); |
|
if (callsTask) { |
|
inSubagentBlock = true; parentTaskRunId = id; turn.kind = 'parent'; |
|
} else if (inSubagentBlock && id !== parentTaskRunId) { |
|
const hasTaskResult = turn.toolResults.some(tr => tr.name === 'task'); |
|
if (hasTaskResult) { turn.kind = 'parent'; inSubagentBlock = false; } |
|
else if (turn.toolResults.length === 0 && turn.toolCalls.length === 0 && !turn.stopReason) { turn.kind = 'parent'; } |
|
else { turn.kind = 'subagent'; } |
|
} |
|
delete turn._tcAccum; |
|
for (const tc of turn.toolCalls) delete tc._argsRaw; |
|
} |
|
|
|
return turnOrder.map(id => turnsMap.get(id)); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// Render helpers |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function pill(text, cls) { return `<span class="pill ${cls}">${esc(text)}</span>`; } |
|
function code(text) { return `<code>${esc(text)}</code>`; } |
|
function esc(s) { |
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); |
|
} |
|
function stopPillClass(r) { |
|
if (!r || r === 'unknown') return 'pill-neutral'; |
|
if (r === 'end_turn') return 'pill-ok'; |
|
if (r === 'tool_use') return 'pill-info'; |
|
if (r === 'max_tokens')return 'pill-err'; |
|
return 'pill-neutral'; |
|
} |
|
|
|
const PARAM_SKIP = new Set(['command', 'contents', 'new_string', 'old_string']); |
|
const PARAM_VAL_MAX = 60; |
|
|
|
function renderToolParams(tc) { |
|
if (!tc.args || typeof tc.args !== 'object') return ''; |
|
const chips = Object.entries(tc.args) |
|
.filter(([k]) => !PARAM_SKIP.has(k)) |
|
.map(([k, v]) => { |
|
let display = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v); |
|
if (display.length > PARAM_VAL_MAX) display = display.slice(0, PARAM_VAL_MAX) + '…'; |
|
return `<span class="tool-param"><span class="tool-param-key">${esc(k)}=</span><span class="tool-param-val">${esc(display)}</span></span>`; |
|
}); |
|
return chips.length ? `<div class="tool-params">${chips.join('')}</div>` : ''; |
|
} |
|
|
|
function copyRunId(btn, id) { |
|
navigator.clipboard.writeText(id).then(() => { |
|
btn.textContent = 'copied!'; btn.classList.add('copied'); |
|
setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 1500); |
|
}); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// Pretty-print toggle on individual JSON lines |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function toggleJsonPretty(el) { |
|
el.classList.toggle('pretty'); |
|
if (el.classList.contains('pretty')) { |
|
try { el.textContent = JSON.stringify(JSON.parse(el.dataset.raw), null, 2); } catch (_) {} |
|
// Highlight the group without toggling meta panel visibility |
|
const row = el.closest('.fl-row'); |
|
if (row) highlightGroup(row.dataset.groupId); |
|
} else { |
|
el.textContent = el.dataset.raw; |
|
} |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// Group (turn) activation — show/hide metadata and highlight lines |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
let _activeGroupId = null; |
|
|
|
function highlightGroup(groupId) { |
|
document.querySelectorAll('.meta-group').forEach(g => { |
|
g.classList.toggle('active', g.dataset.groupId === groupId); |
|
}); |
|
let firstActiveRow = null; |
|
let lastActiveRow = null; |
|
document.querySelectorAll('.fl-row').forEach(r => { |
|
r.classList.remove('group-active', 'group-last', 'parent', 'subagent'); |
|
if (r.dataset.groupId === groupId) { |
|
r.classList.add('group-active', r.dataset.kind); |
|
if (!firstActiveRow) firstActiveRow = r; |
|
lastActiveRow = r; |
|
} |
|
}); |
|
if (lastActiveRow) lastActiveRow.classList.add('group-last'); |
|
return firstActiveRow; |
|
} |
|
|
|
// scrollIntoView within a scrollable container |
|
function scrollIntoContainer(el, container) { |
|
if (!el || !container) return; |
|
container.scrollTop = el.offsetTop - (container.clientHeight - el.offsetHeight) / 2; |
|
} |
|
|
|
// from='left' → selected via meta panel → scroll line-list to first matching fl-row |
|
// from='right' → selected via fl-row → scroll meta-panel to matching meta-group |
|
function activateGroup(groupId, from) { |
|
const metaPanel = document.getElementById('meta-panel'); |
|
const lineList = document.getElementById('line-list'); |
|
|
|
// Toggle off if clicking the already-active group from the same side |
|
if (_activeGroupId === groupId) { |
|
_activeGroupId = null; |
|
document.querySelectorAll('.fl-row').forEach(r => { |
|
r.classList.remove('group-active', 'group-last', 'parent', 'subagent'); |
|
}); |
|
document.querySelectorAll('.meta-group').forEach(g => g.classList.remove('active')); |
|
metaPanel.classList.remove('visible'); |
|
return; |
|
} |
|
|
|
_activeGroupId = groupId; |
|
metaPanel.classList.add('visible'); |
|
const firstRow = highlightGroup(groupId); |
|
|
|
if (from === 'left') { |
|
// Selected from meta panel → scroll line-list to first matching row |
|
scrollIntoContainer(firstRow, lineList); |
|
} else { |
|
// Selected from line list → scroll meta-panel to matching meta-group |
|
const metaGroup = metaPanel.querySelector(`.meta-group[data-group-id="${groupId}"]`); |
|
scrollIntoContainer(metaGroup, metaPanel); |
|
} |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// Build & inject the flat line list + meta panel |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function renderLines(turns, allEntries) { |
|
// Build a lineNo → turn index map for O(1) lookup |
|
const lineToTurn = new Map(); |
|
turns.forEach((turn, i) => { |
|
for (const e of turn.entries) lineToTurn.set(e.lineNo, i); |
|
}); |
|
|
|
// ── Line list HTML ──────────────────────────────────────────────────────── |
|
const lineHTML = allEntries.map(e => { |
|
const turnIdx = lineToTurn.get(e.lineNo); |
|
const groupId = turnIdx != null ? String(turnIdx) : 'none'; |
|
const kind = turnIdx != null ? turns[turnIdx].kind : 'parent'; |
|
return `<div class="fl-row" data-group-id="${groupId}" data-kind="${kind}" |
|
onclick="activateGroup('${groupId}','right')"> |
|
<div class="fl-lineno">${e.lineNo}</div> |
|
<div class="fl-json" data-raw="${esc(e.raw)}" |
|
onclick="event.stopPropagation(); toggleJsonPretty(this)">${esc(e.raw)}</div> |
|
</div>`; |
|
}).join(''); |
|
|
|
// ── Meta panel HTML ─────────────────────────────────────────────────────── |
|
const metaHTML = turns.map((turn, i) => { |
|
const lineSpan = turn.endLine - turn.startLine + 1; |
|
const kindPill = turn.kind === 'parent' |
|
? pill('parent', 'pill-info') |
|
: pill('subagent', 'pill-warn'); |
|
const stopPill = turn.stopReason |
|
? pill(turn.stopReason, stopPillClass(turn.stopReason)) |
|
: `<span class="mono-dim">—</span>`; |
|
|
|
let toolsHTML = ''; |
|
for (const tc of turn.toolCalls) { |
|
let head = `<span class="meta-tool">${code(tc.name)}`; |
|
if (tc.pattern) head += ` ${code(tc.pattern)}`; |
|
else if (tc.subagentType) head += ` ${code(tc.subagentType)}`; |
|
else if (tc.path) head += ` <span class="mono-dim">${esc(tc.path.slice(-32))}</span>`; |
|
head += `</span>`; |
|
toolsHTML += head + renderToolParams(tc); |
|
} |
|
for (const tr of turn.toolResults) { |
|
const cls = tr.status === 'error' ? 'pill-err' : 'pill-ok'; |
|
toolsHTML += `<div class="meta-tool-result">${pill('↳ ' + tr.name, cls)}</div>`; |
|
if (tr.snippet) toolsHTML += `<div class="meta-snip">${esc(tr.snippet.slice(0, 80))}</div>`; |
|
} |
|
|
|
return `<div class="meta-group ${turn.kind}" data-group-id="${i}" |
|
onclick="activateGroup('${i}','left')"> |
|
<div class="meta-group-header"> |
|
${kindPill} |
|
${stopPill} |
|
<span class="meta-lines">${turn.startLine}–${turn.endLine} (${lineSpan})</span> |
|
</div> |
|
<div class="meta-runid"> |
|
${esc(turn.runId.slice(0, 20))} |
|
<button class="copy-btn" onclick="event.stopPropagation(); copyRunId(this,'${esc(turn.runId)}')">copy</button> |
|
</div> |
|
${toolsHTML ? `<div class="meta-tools">${toolsHTML}</div>` : ''} |
|
</div>`; |
|
}).join(''); |
|
|
|
document.getElementById('line-list').innerHTML = lineHTML; |
|
document.getElementById('meta-panel').innerHTML = metaHTML; |
|
// Panel starts hidden; _activeGroupId is null |
|
_activeGroupId = null; |
|
document.getElementById('meta-panel').classList.remove('visible'); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// Metadata modal |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
let _modalHTML = ''; |
|
|
|
function showMetadataModal() { |
|
if (!_modalHTML) return; |
|
document.getElementById('modal-body').innerHTML = _modalHTML; |
|
document.getElementById('modal-backdrop').classList.add('open'); |
|
} |
|
|
|
function closeMetadataModal(e) { |
|
// Close on backdrop click or explicit call (no event / close button) |
|
if (e && e.target !== document.getElementById('modal-backdrop')) return; |
|
document.getElementById('modal-backdrop').classList.remove('open'); |
|
} |
|
|
|
document.addEventListener('keydown', e => { |
|
if (e.key === 'Escape') document.getElementById('modal-backdrop').classList.remove('open'); |
|
}); |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// Main parse + render |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function parseAndRender(text, fileName) { |
|
const errorArea = document.getElementById('error-area'); |
|
errorArea.innerHTML = ''; |
|
|
|
let entries; |
|
try { entries = parseJSONL(text); } |
|
catch (e) { errorArea.innerHTML = `<div class="error-box">Parse error: ${esc(e.message)}</div>`; return; } |
|
|
|
if (entries.length === 0) { |
|
errorArea.innerHTML = `<div class="error-box">No valid JSONL lines found in file.</div>`; |
|
return; |
|
} |
|
|
|
const turns = buildTurns(entries); |
|
const parentTurns = turns.filter(t => t.kind === 'parent'); |
|
const subagentTurns = turns.filter(t => t.kind === 'subagent'); |
|
const allToolCalls = turns.flatMap(t => t.toolCalls); |
|
const toolNames = [...new Set(allToolCalls.map(tc => tc.name))]; |
|
|
|
document.getElementById('file-info').textContent = `${fileName} · ${entries.length} lines`; |
|
|
|
// ── Build modal content ────────────────────────────────────────────────── |
|
const statsHTML = [ |
|
[turns.length, 'turns'], |
|
[parentTurns.length, 'parent'], |
|
[subagentTurns.length, 'subagent'], |
|
[allToolCalls.length, 'tool calls'], |
|
[toolNames.join(', ') || '—', 'tool names'], |
|
].map(([v, l]) => |
|
`<div class="stat"><div class="stat-value">${esc(String(v))}</div><div class="stat-label">${l}</div></div>` |
|
).join(''); |
|
|
|
const grepCalls = allToolCalls.filter(tc => tc.name === 'grep'); |
|
const obsHTML = [ |
|
{ pill: 'subagent', cls: 'pill-warn', |
|
body: `Each subagent turn gets its own <code>lc_run--</code> id. The ${subagentTurns.length} subagent turns appear as a flat sequence — no nesting.` }, |
|
{ pill: 'task', cls: 'pill-info', |
|
body: `A subagent call is a normal <code>tool_use</code> named <code>task</code>. The distinguishing info lives inside the streamed arg JSON (e.g. <code>subagent_type</code>).` }, |
|
{ pill: 'grep', cls: 'pill-neutral', |
|
body: `${grepCalls.length} grep call${grepCalls.length !== 1 ? 's' : ''} detected. ${grepCalls.map(tc => `<code>${esc(tc.pattern || '?')}</code>`).join(' ')}` }, |
|
{ pill: 'result', cls: 'pill-ok', |
|
body: `The subagent's final response is delivered as a plain <code>type:"tool"</code> message, identical in structure to any other tool result.` }, |
|
].map(o => `<div class="obs-item"><span class="pill ${o.cls}">${o.pill}</span><div class="obs-body">${o.body}</div></div>`).join(''); |
|
|
|
const inventoryHTML = turns.flatMap(turn => |
|
turn.toolCalls.map(tc => { |
|
const patternCell = tc.pattern |
|
? `<code>${esc(tc.pattern)}</code>` |
|
: tc.subagentType ? `<code>${esc(tc.subagentType)}</code>` |
|
: tc.path ? `<span class="mono-dim">${esc(tc.path.slice(-50))}</span>` |
|
: tc.args && Object.keys(tc.args).length > 0 |
|
? (() => { |
|
const es = Object.entries(tc.args).filter(([k]) => !PARAM_SKIP.has(k)); |
|
if (!es.length) return '<span class="mono-dim">—</span>'; |
|
return es.map(([k, v]) => { |
|
let d = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v); |
|
if (d.length > 50) d = d.slice(0, 50) + '…'; |
|
return `<span class="tool-param"><span class="tool-param-key">${esc(k)}=</span><span class="tool-param-val">${esc(d)}</span></span>`; |
|
}).join(' '); |
|
})() |
|
: '<span class="mono-dim">—</span>'; |
|
return `<tr class="${turn.kind}-row"> |
|
<td class="mono">${tc.line}</td> |
|
<td>${turn.kind === 'parent' ? pill('parent','pill-info') : pill('subagent','pill-warn')}</td> |
|
<td class="mono">${esc(turn.runId.slice(0, 17))}</td> |
|
<td>${code(tc.name)}</td> |
|
<td>${patternCell}</td> |
|
<td class="mono-dim">${esc(tc.toolCallId)}</td> |
|
</tr>`; |
|
}) |
|
).join(''); |
|
|
|
_modalHTML = ` |
|
<div class="modal-stats">${statsHTML}</div> |
|
<div> |
|
<h2 class="section-title" style="margin-bottom:10px">Observations</h2> |
|
<div class="modal-obs obs-grid">${obsHTML}</div> |
|
</div> |
|
<div> |
|
<h2 class="section-title" style="margin-bottom:10px">Tool call inventory</h2> |
|
${inventoryHTML |
|
? `<table class="modal-inventory"> |
|
<thead><tr><th>line</th><th>kind</th><th>run id</th><th>tool</th><th>pattern / args</th><th>tool call id</th></tr></thead> |
|
<tbody>${inventoryHTML}</tbody> |
|
</table>` |
|
: `<span style="color:var(--text-dim);font-size:12px">No tool calls found.</span>`} |
|
</div>`; |
|
|
|
renderLines(turns, entries); |
|
|
|
saveSelected(fileName); |
|
dropZone.style.display = 'none'; |
|
appEl.classList.add('visible'); |
|
window.scrollTo(0, 0); |
|
} |
|
|
|
// Restore previously selected trace |
|
(function restoreSelected() { |
|
const name = loadSelected(); |
|
if (!name) return; |
|
const entry = loadRecent().find(r => r.name === name); |
|
if (entry && entry.content) parseAndRender(entry.content, entry.name); |
|
})(); |
|
</script> |
|
</body> |
|
</html> |