3788 lines
212 KiB
HTML
3788 lines
212 KiB
HTML
<!DOCTYPE html>
|
||
<!--
|
||
Copyright 2026 Google LLC
|
||
|
||
Licensed under the Apache License, Version 2.0 (the "License");
|
||
you may not use this file except in compliance with the License.
|
||
You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing, software
|
||
distributed under the License is distributed on an "AS IS" BASIS,
|
||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
See the License for the specific language governing permissions and
|
||
limitations under the License.
|
||
-->
|
||
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Artemis Debug UI</title>
|
||
<link rel="icon" type="image/png" sizes="192x192" href="/logo.png?v=2">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/logo.png?v=2">
|
||
<link rel="icon" type="image/svg+xml" href="/logo.svg?v=2">
|
||
<link rel="shortcut icon" href="/favicon.ico?v=2">
|
||
<link rel="apple-touch-icon" href="/logo.png?v=2">
|
||
<style>
|
||
pre.light-code {
|
||
background: transparent !important;
|
||
padding: 0 !important;
|
||
color: inherit !important;
|
||
box-shadow: none !important;
|
||
border-radius: 0 !important;
|
||
}
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
margin: 0;
|
||
display: flex;
|
||
height: 100vh;
|
||
background-color: #f5f7fb;
|
||
color: #333;
|
||
}
|
||
#sidebar {
|
||
width: 350px;
|
||
background-color: #fff;
|
||
border-right: 1px solid #e0e6ed;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 20px;
|
||
box-shadow: 2px 0 5px rgba(0,0,0,0.05);
|
||
}
|
||
#content {
|
||
flex: 1;
|
||
padding: 30px;
|
||
overflow-y: auto;
|
||
background-color: #f9fafc;
|
||
}
|
||
h1, h2, h3 {
|
||
margin-top: 0;
|
||
color: #1a202c;
|
||
}
|
||
.session-item {
|
||
padding: 12px;
|
||
border: 1px solid #e2e8f0;
|
||
margin-bottom: 10px;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
transition: background-color 0.2s, border-color 0.2s;
|
||
background-color: #fff;
|
||
}
|
||
.session-item:hover {
|
||
background-color: #edf2f7;
|
||
border-color: #cbd5e0;
|
||
}
|
||
.session-item.active {
|
||
background-color: #3182ce;
|
||
color: white;
|
||
border-color: #3182ce;
|
||
}
|
||
.session-item.active h3 {
|
||
color: white;
|
||
}
|
||
.tree-node {
|
||
margin-left: 20px;
|
||
border-left: 2px solid #cbd5e0;
|
||
padding-left: 15px;
|
||
position: relative;
|
||
}
|
||
.tree-node::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: -2px;
|
||
top: 12px;
|
||
width: 10px;
|
||
border-top: 2px solid #cbd5e0;
|
||
}
|
||
.node-header {
|
||
padding: 8px 12px;
|
||
background-color: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
margin-bottom: 6px;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
transition: background-color 0.2s;
|
||
}
|
||
.node-header:hover {
|
||
background-color: #edf2f7;
|
||
}
|
||
.node-header.active {
|
||
border-color: #3182ce;
|
||
background-color: #ebf8ff;
|
||
}
|
||
.status-success { color: #38a169; font-weight: bold; }
|
||
.status-failed { color: #e53e3e; font-weight: bold; }
|
||
.status-running { color: #d69e2e; font-weight: bold; }
|
||
|
||
.type-agent { background-color: #e6fffa; border-color: #b2f5ea; color: #234e52; }
|
||
.type-tool { background-color: #ebf8ff; border-color: #bee3f8; color: #2c5282; }
|
||
.type-span { background-color: #fffaf0; border-color: #feebc8; color: #744210; }
|
||
.type-llm_call { background-color: #f7fafc; border-color: #edf2f7; color: #4a5568; }
|
||
.type-node { background-color: #faf5ff; border-color: #e9d8fd; color: #44337a; }
|
||
.type-action { background-color: #fff5f5; border-color: #fed7d7; color: #7b341e; }
|
||
|
||
.pill {
|
||
font-size: 0.75rem;
|
||
padding: 2px 6px;
|
||
border-radius: 9999px;
|
||
text-transform: uppercase;
|
||
font-weight: bold;
|
||
}
|
||
|
||
pre {
|
||
background-color: #2d3748;
|
||
color: #fff;
|
||
padding: 15px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
font-family: 'Courier New', Courier, monospace;
|
||
font-size: 0.9rem;
|
||
}
|
||
.detail-section {
|
||
background-color: #fff;
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
border: 1px solid #e0e6ed;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||
}
|
||
.streaming-active {
|
||
border-color: #4299e1;
|
||
box-shadow: 0 0 12px rgba(66, 153, 225, 0.4);
|
||
}
|
||
img, video {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: 6px;
|
||
border: 1px solid #cbd5e0;
|
||
margin-top: 10px;
|
||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||
}
|
||
.media-container {
|
||
margin-top: 15px;
|
||
background-color: #f7fafc;
|
||
padding: 15px;
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
}
|
||
.media-label {
|
||
font-size: 0.85rem;
|
||
color: #718096;
|
||
margin-bottom: 5px;
|
||
display: block;
|
||
}
|
||
#session-list {
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
.refresh-btn {
|
||
padding: 8px 12px;
|
||
background-color: #3182ce;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-weight: bold;
|
||
margin-bottom: 15px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
.refresh-btn:hover {
|
||
background-color: #2b6cb0;
|
||
}
|
||
.node-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.horizontal-dashboard {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
overflow-x: auto;
|
||
gap: 16px;
|
||
padding-bottom: 12px;
|
||
margin-top: 10px;
|
||
}
|
||
.horizontal-dashboard > .tree-node {
|
||
flex: 0 0 auto;
|
||
width: 420px;
|
||
max-width: 85vw;
|
||
border-left: none;
|
||
margin-left: 0;
|
||
padding-left: 0;
|
||
background-color: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
||
}
|
||
.horizontal-dashboard > .tree-node::before {
|
||
display: none;
|
||
}
|
||
@keyframes pulse-animation {
|
||
0% {
|
||
transform: scale(0.5);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: scale(1.5);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="sidebar">
|
||
<h2>Run Task</h2>
|
||
<div style="margin-bottom: 20px;">
|
||
<textarea id="task-goal" style="width: 100%; height: 60px; border-radius: 4px; border: 1px solid #e2e8f0; padding: 8px; box-sizing: border-box; font-family: inherit;" placeholder="Enter task goal..."></textarea>
|
||
<div style="margin-top: 8px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; font-size: 0.85rem; color: #475569;">
|
||
<label for="task-profile-select" style="font-weight: 600;">Agent Architecture:</label>
|
||
<select id="task-profile-select" style="padding: 4px 8px; border-radius: 4px; border: 1px solid #cbd5e0; font-size: 0.85rem; background-color: #f8fafc; color: #334155; outline: none; cursor: pointer; flex: 1; margin-left: 10px;">
|
||
<option value="flash">⚡ Flash (Reactive Fast Loop)</option>
|
||
<option value="pro">🚀 Pro (Multi-Agent Cognitive Graph)</option>
|
||
</select>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
||
<button class="refresh-btn" style="flex: 1; margin-bottom: 0;" onclick="runTask()">Run Task</button>
|
||
<button class="refresh-btn" style="flex: 1; margin-bottom: 0; background-color: #e53e3e;" onclick="stopTask()">Stop Task</button>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
||
<button class="refresh-btn" id="resume-btn" style="flex: 1; margin-bottom: 0; background-color: #38a169; display: none;" onclick="resumeTask()">Resume Task</button>
|
||
</div>
|
||
<div id="run-status" style="font-size: 0.85rem; margin-top: 5px;"></div>
|
||
</div>
|
||
<h2>Sessions</h2>
|
||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||
<button class="refresh-btn" style="flex: 1; margin-bottom: 0;" onclick="loadSessions()">Refresh List</button>
|
||
<button class="refresh-btn" style="flex: 1; margin-bottom: 0; background-color: #e53e3e;" onclick="cleanupHistory()">Clean History</button>
|
||
</div>
|
||
<div style="margin-bottom: 15px;">
|
||
<input type="text" id="session-search-input" oninput="filterSessionsList()" placeholder="🔍 Filter by Session ID or Goal..." style="width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid #cbd5e0; font-size: 0.85rem; box-sizing: border-box; outline: none; background-color: #fff; color: #1e293b; transition: border-color 0.2s, box-shadow 0.2s;" onfocus="this.style.borderColor='#3182ce'; this.style.boxShadow='0 0 0 2px rgba(49,130,206,0.2)';" onblur="this.style.borderColor='#cbd5e0'; this.style.boxShadow='none';"/>
|
||
</div>
|
||
<div id="session-list">Loading sessions...</div>
|
||
</div>
|
||
<div id="content">
|
||
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; margin-bottom: 10px;">
|
||
<div style="display: flex; align-items: flex-start; gap: 15px; flex: 1;">
|
||
<button onclick="toggleSidebar()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; padding: 0; color: #4a5568; margin-top: 5px;" title="Toggle Sidebar">☰</button>
|
||
<div id="session-header" style="flex: 1;">
|
||
<h1 style="margin-top: 0;">Select a session from the sidebar</h1>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Tab Navigation Bar -->
|
||
<div id="session-tabs-bar" style="display: none; gap: 8px; border-bottom: 2px solid #edf2f7; padding-bottom: 8px; margin-bottom: 15px; align-items: center;">
|
||
<button class="tab-btn active" onclick="switchTab('steps')" style="padding: 8px 16px; border: none; background: none; font-weight: bold; font-size: 0.95rem; cursor: pointer; border-bottom: 2px solid #3182ce; color: #3182ce; transition: color 0.2s, border-color 0.2s; outline: none;">👣 Steps Timeline</button>
|
||
<button class="tab-btn" onclick="switchTab('traces')" style="padding: 8px 16px; border: none; background: none; font-weight: bold; font-size: 0.95rem; cursor: pointer; color: #718096; transition: color 0.2s, border-color 0.2s; outline: none;">🌳 Traces Tree</button>
|
||
<button class="tab-btn" onclick="switchTab('replay')" style="padding: 8px 16px; border: none; background: none; font-weight: bold; font-size: 0.95rem; cursor: pointer; color: #718096; transition: color 0.2s, border-color 0.2s; outline: none;">🔄 Step Replay</button>
|
||
|
||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||
<button class="refresh-btn" style="margin-bottom: 0; background-color: #38bdf8; color: #0f172a; font-weight: bold;" onclick="openNotesModal()">📚 View All Notes</button>
|
||
<button class="refresh-btn" style="margin-bottom: 0; background-color: #38bdf8; color: #0f172a; font-weight: bold;" onclick="openPlanModal()">📋 View Task Plan</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Steps Timeline Tab Panel -->
|
||
<div id="steps-container" style="display: none;">
|
||
<div style="padding: 20px; color: #718096; text-align: center;">Select a session from the sidebar to view steps.</div>
|
||
</div>
|
||
|
||
<!-- Traces Tree Tab Panel -->
|
||
<div id="traces-container-wrapper" style="display: none;">
|
||
<div style="margin-bottom: 10px; display: flex; align-items: center; gap: 10px;">
|
||
<label style="font-size: 0.9rem; color: #4a5568; cursor: pointer; user-select: none;">
|
||
<input type="checkbox" id="show-logs-check" onchange="loadTree()" style="margin-right: 4px;"/> Show Log Traces
|
||
</label>
|
||
</div>
|
||
<div id="tree-container"></div>
|
||
<div id="details-container"></div>
|
||
</div>
|
||
|
||
<!-- Step Replay Tab Panel -->
|
||
<div id="replay-container-wrapper" style="display: none; height: calc(100vh - 180px); overflow-y: auto; padding-right: 10px;">
|
||
<!-- Global Replay Header (Device Selector, etc.) -->
|
||
<!-- Global Replay Header -->
|
||
<div style="background-color: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px 20px; display: flex; align-items: center; justify-content: space-between; gap: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.02); margin-bottom: 20px; flex-shrink: 0;">
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<span style="font-size: 1.25rem;">🔄</span>
|
||
<h2 style="margin: 0; font-size: 1.1rem; color: #0f172a; font-weight: 700;">Interactive Step Replay Timeline</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Horizontal Steps Navigation Bar (Sticky/Pinned) -->
|
||
<div id="replay-steps-nav" style="display: none; gap: 12px; overflow-x: auto; padding: 12px 10px; margin-bottom: 20px; scroll-behavior: smooth; -webkit-overflow-scrolling: touch; border-bottom: 1px solid #e2e8f0; position: sticky; top: 0; background: rgba(255, 255, 255, 0.97); z-index: 50; box-shadow: 0 4px 6px -4px rgba(0,0,0,0.05);">
|
||
<!-- Step nav items will be dynamically populated here -->
|
||
</div>
|
||
|
||
<!-- Steps Replay Vertical Timeline Container -->
|
||
<div id="replay-timeline-container">
|
||
<div style="padding: 40px; color: #718096; text-align: center; font-size: 1.1rem;">Select a session from the sidebar to load the replay timeline.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Task Plan Modal -->
|
||
<div id="plan-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(15, 23, 42, 0.65); backdrop-filter: blur(8px); z-index: 9999; justify-content: center; align-items: center;">
|
||
<div style="background-color: #1e293b; color: #f8fafc; width: 700px; max-width: 90%; max-height: 80vh; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #334155;">
|
||
<div style="padding: 16px 24px; background-color: #0f172a; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center;">
|
||
<h2 style="margin: 0; color: #38bdf8; font-size: 1.25rem; font-weight: 600;">📋 Active Task Plan</h2>
|
||
<button onclick="closePlanModal()" style="background: none; border: none; color: #94a3b8; font-size: 1.5rem; cursor: pointer; line-height: 1;">×</button>
|
||
</div>
|
||
<div style="padding: 24px; overflow-y: auto; flex: 1;">
|
||
<pre id="plan-modal-content" style="margin: 0; background-color: #0f172a; padding: 16px; border-radius: 8px; color: #e2e8f0; font-family: 'Courier New', Courier, monospace; font-size: 0.9rem; white-space: pre-wrap;">Loading task plan...</pre>
|
||
</div>
|
||
<div style="padding: 12px 24px; background-color: #0f172a; border-top: 1px solid #334155; text-align: right;">
|
||
<button class="refresh-btn" style="margin: 0; background-color: #38bdf8; color: #0f172a; font-weight: bold;" onclick="fetchTaskPlan()">Refresh Plan</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- All Notes Modal -->
|
||
<div id="notes-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(15, 23, 42, 0.65); backdrop-filter: blur(8px); z-index: 9999; justify-content: center; align-items: center;">
|
||
<div style="background-color: #1e293b; color: #f8fafc; width: 800px; max-width: 90%; max-height: 80vh; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #334155;">
|
||
<div style="padding: 16px 24px; background-color: #0f172a; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center;">
|
||
<h2 style="margin: 0; color: #38bdf8; font-size: 1.25rem; font-weight: 600;">📚 All Notes</h2>
|
||
<button onclick="closeNotesModal()" style="background: none; border: none; color: #94a3b8; font-size: 1.5rem; cursor: pointer; line-height: 1;">×</button>
|
||
</div>
|
||
<div style="padding: 24px; overflow-y: auto; flex: 1;" id="notes-modal-content">
|
||
Loading notes...
|
||
</div>
|
||
<div style="padding: 12px 24px; background-color: #0f172a; border-top: 1px solid #334155; text-align: right;">
|
||
<button class="refresh-btn" style="margin: 0; background-color: #38bdf8; color: #0f172a; font-weight: bold;" onclick="fetchAllNotes()">Refresh Notes</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function toggleSidebar() {
|
||
const sidebar = document.getElementById('sidebar');
|
||
if (sidebar.style.display === 'none') {
|
||
sidebar.style.display = 'flex';
|
||
} else {
|
||
sidebar.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Coordinate visualization helpers
|
||
function createPointMarker(x, y, actionType, overlay, isNormalized, sequenceIndex, totalSequence) {
|
||
let pctX = x / 10;
|
||
let pctY = y / 10;
|
||
|
||
const container = overlay.parentElement;
|
||
const img = container ? container.querySelector('img') : null;
|
||
if (!isNormalized && img && img.naturalWidth && img.naturalHeight) {
|
||
if (x > 1000 || y > 1000 || (x > 100 && y > 100 && img.naturalWidth > 500)) {
|
||
pctX = (x / img.naturalWidth) * 100;
|
||
pctY = (y / img.naturalHeight) * 100;
|
||
}
|
||
}
|
||
|
||
const marker = document.createElement('div');
|
||
marker.style.position = 'absolute';
|
||
marker.style.left = `${pctX}%`;
|
||
marker.style.top = `${pctY}%`;
|
||
marker.style.transform = 'translate(-50%, -50%)';
|
||
marker.style.zIndex = '10';
|
||
marker.style.pointerEvents = 'none';
|
||
|
||
const dotSize = sequenceIndex !== undefined ? 20 : 14;
|
||
const dot = document.createElement('div');
|
||
dot.style.width = `${dotSize}px`;
|
||
dot.style.height = `${dotSize}px`;
|
||
dot.style.borderRadius = '50%';
|
||
dot.style.backgroundColor = '#ef4444';
|
||
dot.style.border = '2px solid white';
|
||
dot.style.boxShadow = '0 0 6px rgba(0,0,0,0.4)';
|
||
if (sequenceIndex !== undefined) {
|
||
dot.style.display = 'flex';
|
||
dot.style.alignItems = 'center';
|
||
dot.style.justifyContent = 'center';
|
||
dot.style.fontSize = '11px';
|
||
dot.style.fontWeight = 'bold';
|
||
dot.style.color = 'white';
|
||
dot.innerText = String(sequenceIndex);
|
||
}
|
||
|
||
const pulseSize = dotSize * 2;
|
||
const pulse = document.createElement('div');
|
||
pulse.style.position = 'absolute';
|
||
pulse.style.top = `-${dotSize / 2 + 2}px`;
|
||
pulse.style.left = `-${dotSize / 2 + 2}px`;
|
||
pulse.style.width = `${pulseSize}px`;
|
||
pulse.style.height = `${pulseSize}px`;
|
||
pulse.style.borderRadius = '50%';
|
||
pulse.style.border = '2px solid #ef4444';
|
||
pulse.style.animation = 'pulse-animation 1.5s ease-out 3 forwards';
|
||
pulse.style.pointerEvents = 'none';
|
||
|
||
marker.appendChild(pulse);
|
||
marker.appendChild(dot);
|
||
|
||
const label = document.createElement('div');
|
||
if (sequenceIndex !== undefined) {
|
||
label.innerText = totalSequence && totalSequence > 1 ? `#${sequenceIndex} TAP` : actionType.toUpperCase();
|
||
} else {
|
||
label.innerText = actionType.toUpperCase();
|
||
}
|
||
label.style.position = 'absolute';
|
||
label.style.top = `${dotSize + 2}px`;
|
||
label.style.left = '50%';
|
||
label.style.transform = 'translateX(-50%)';
|
||
label.style.backgroundColor = 'rgba(15, 23, 42, 0.9)';
|
||
label.style.color = 'white';
|
||
label.style.padding = '1.5px 5px';
|
||
label.style.borderRadius = '3px';
|
||
label.style.fontSize = '8px';
|
||
label.style.fontWeight = 'bold';
|
||
label.style.whiteSpace = 'nowrap';
|
||
label.style.boxShadow = '0 1px 3px rgba(0,0,0,0.3)';
|
||
|
||
marker.appendChild(label);
|
||
overlay.appendChild(marker);
|
||
}
|
||
|
||
function createSequenceConnector(x1, y1, x2, y2, overlay, isNormalized) {
|
||
let pctX1 = x1 / 10;
|
||
let pctY1 = y1 / 10;
|
||
let pctX2 = x2 / 10;
|
||
let pctY2 = y2 / 10;
|
||
|
||
const container = overlay.parentElement;
|
||
const img = container ? container.querySelector('img') : null;
|
||
if (!isNormalized && img && img.naturalWidth && img.naturalHeight) {
|
||
if (x1 > 1000 || y1 > 1000 || x2 > 1000 || y2 > 1000 || (x1 > 100 && y1 > 100 && img.naturalWidth > 500)) {
|
||
pctX1 = (x1 / img.naturalWidth) * 100;
|
||
pctY1 = (y1 / img.naturalHeight) * 100;
|
||
pctX2 = (x2 / img.naturalWidth) * 100;
|
||
pctY2 = (y2 / img.naturalHeight) * 100;
|
||
}
|
||
}
|
||
|
||
let svg = overlay.querySelector('svg');
|
||
if (!svg) {
|
||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('width', '100%');
|
||
svg.setAttribute('height', '100%');
|
||
svg.style.position = 'absolute';
|
||
svg.style.top = '0';
|
||
svg.style.left = '0';
|
||
svg.style.pointerEvents = 'none';
|
||
|
||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||
marker.setAttribute('id', 'seq-arrow');
|
||
marker.setAttribute('viewBox', '0 0 10 10');
|
||
marker.setAttribute('refX', '5');
|
||
marker.setAttribute('refY', '5');
|
||
marker.setAttribute('markerWidth', '6');
|
||
marker.setAttribute('markerHeight', '6');
|
||
marker.setAttribute('orient', 'auto');
|
||
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
||
path.setAttribute('fill', '#ef4444');
|
||
|
||
marker.appendChild(path);
|
||
defs.appendChild(marker);
|
||
svg.appendChild(defs);
|
||
overlay.appendChild(svg);
|
||
}
|
||
|
||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||
line.setAttribute('x1', `${pctX1}%`);
|
||
line.setAttribute('y1', `${pctY1}%`);
|
||
line.setAttribute('x2', `${pctX2}%`);
|
||
line.setAttribute('y2', `${pctY2}%`);
|
||
line.setAttribute('stroke', '#ef4444');
|
||
line.setAttribute('stroke-width', '2.5');
|
||
line.setAttribute('marker-end', 'url(#seq-arrow)');
|
||
line.setAttribute('stroke-dasharray', '4,4');
|
||
|
||
svg.appendChild(line);
|
||
}
|
||
|
||
function createLineMarker(x1, y1, x2, y2, overlay, isNormalized) {
|
||
let pctX1 = x1 / 10;
|
||
let pctY1 = y1 / 10;
|
||
let pctX2 = x2 / 10;
|
||
let pctY2 = y2 / 10;
|
||
|
||
const container = overlay.parentElement;
|
||
const img = container ? container.querySelector('img') : null;
|
||
if (!isNormalized && img && img.naturalWidth && img.naturalHeight) {
|
||
if (x1 > 1000 || y1 > 1000 || x2 > 1000 || y2 > 1000 || (x1 > 100 && y1 > 100 && img.naturalWidth > 500)) {
|
||
pctX1 = (x1 / img.naturalWidth) * 100;
|
||
pctY1 = (y1 / img.naturalHeight) * 100;
|
||
pctX2 = (x2 / img.naturalWidth) * 100;
|
||
pctY2 = (y2 / img.naturalHeight) * 100;
|
||
}
|
||
}
|
||
|
||
let svg = overlay.querySelector('svg');
|
||
if (!svg) {
|
||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('width', '100%');
|
||
svg.setAttribute('height', '100%');
|
||
svg.style.position = 'absolute';
|
||
svg.style.top = '0';
|
||
svg.style.left = '0';
|
||
svg.style.pointerEvents = 'none';
|
||
|
||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||
marker.setAttribute('id', 'arrow');
|
||
marker.setAttribute('viewBox', '0 0 10 10');
|
||
marker.setAttribute('refX', '5');
|
||
marker.setAttribute('refY', '5');
|
||
marker.setAttribute('markerWidth', '6');
|
||
marker.setAttribute('markerHeight', '6');
|
||
marker.setAttribute('orient', 'auto-start-reverse');
|
||
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
||
path.setAttribute('fill', '#ef4444');
|
||
|
||
marker.appendChild(path);
|
||
defs.appendChild(marker);
|
||
svg.appendChild(defs);
|
||
overlay.appendChild(svg);
|
||
}
|
||
|
||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||
line.setAttribute('x1', `${pctX1}%`);
|
||
line.setAttribute('y1', `${pctY1}%`);
|
||
line.setAttribute('x2', `${pctX2}%`);
|
||
line.setAttribute('y2', `${pctY2}%`);
|
||
line.setAttribute('stroke', '#ef4444');
|
||
line.setAttribute('stroke-width', '3');
|
||
line.setAttribute('marker-end', 'url(#arrow)');
|
||
line.setAttribute('stroke-dasharray', '5,5');
|
||
|
||
const startCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||
startCircle.setAttribute('cx', `${pctX1}%`);
|
||
startCircle.setAttribute('cy', `${pctY1}%`);
|
||
startCircle.setAttribute('r', '4');
|
||
startCircle.setAttribute('fill', '#ef4444');
|
||
startCircle.setAttribute('stroke', 'white');
|
||
startCircle.setAttribute('stroke-width', '1.5');
|
||
|
||
svg.appendChild(line);
|
||
svg.appendChild(startCircle);
|
||
}
|
||
|
||
function drawActionCoordinates(actionObj, overlayElement) {
|
||
if (!actionObj || !overlayElement) return;
|
||
overlayElement.innerHTML = '';
|
||
|
||
const actions = Array.isArray(actionObj) ? actionObj : [actionObj];
|
||
|
||
actions.forEach(act => {
|
||
if (!act || typeof act !== 'object') return;
|
||
|
||
const actionName = (act.action || act.name || 'click').toLowerCase();
|
||
const args = (act.payload && act.payload.args) || act.args || {};
|
||
|
||
// Filter out non-touch / system actions that do not operate via touch coordinates
|
||
const nonDrawingActions = [
|
||
'manage_app', 'launch_app', 'open_app', 'stop_app', 'close_app',
|
||
'press_key', 'press_home', 'press_back', 'wait_for_delay', 'wait', 'delay',
|
||
'report_task_status', 'report_failure_analysis'
|
||
];
|
||
if (nonDrawingActions.includes(actionName)) {
|
||
return;
|
||
}
|
||
|
||
// 1. Multi-point click sequence
|
||
const isSequenceAction = actionName.includes('sequence') || Boolean(act.sequence || args.sequence || act.normalized_sequence || args.normalized_sequence);
|
||
let rawSeq = act.normalized_sequence || args.normalized_sequence || act.sequence || args.sequence || act.targets || args.targets;
|
||
if (typeof rawSeq === 'string') {
|
||
try { rawSeq = JSON.parse(rawSeq); } catch (e) {}
|
||
}
|
||
if (Array.isArray(rawSeq) && rawSeq.length > 0) {
|
||
const validPoints = [];
|
||
rawSeq.forEach(pt => {
|
||
if (typeof pt === 'string') {
|
||
try { pt = JSON.parse(pt); } catch (e) {}
|
||
}
|
||
if (Array.isArray(pt) && pt.length >= 2 && typeof pt[0] === 'number' && typeof pt[1] === 'number') {
|
||
validPoints.push([pt[0], pt[1]]);
|
||
}
|
||
});
|
||
if (validPoints.length > 0 && (isSequenceAction || validPoints.length > 1)) {
|
||
const isNormSeq = Boolean(act.normalized_sequence || args.normalized_sequence) || validPoints.every(p => p[0] <= 1000 && p[1] <= 1000);
|
||
for (let i = 0; i < validPoints.length - 1; i++) {
|
||
createSequenceConnector(validPoints[i][0], validPoints[i][1], validPoints[i+1][0], validPoints[i+1][1], overlayElement, isNormSeq);
|
||
}
|
||
validPoints.forEach((pt, idx) => {
|
||
createPointMarker(pt[0], pt[1], actionName, overlayElement, isNormSeq, idx + 1, validPoints.length);
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
const isSwipeLike = actionName.includes('swipe') || actionName.includes('scroll') || actionName.includes('drag') || actionName.includes('slide');
|
||
|
||
// 2. Separate start and end coordinates
|
||
const normStart = act.normalized_start_coordinates || args.normalized_start_coordinates;
|
||
const normEnd = act.normalized_end_coordinates || args.normalized_end_coordinates;
|
||
const startCoords = normStart || act.start_coordinates || act.start_point || act.start || act.from ||
|
||
args.start_coordinates || args.start_point || args.start || args.from;
|
||
const endCoords = normEnd || act.end_coordinates || act.end_point || act.end || act.to ||
|
||
args.end_coordinates || args.end_point || args.end || args.to;
|
||
if (startCoords && Array.isArray(startCoords) && endCoords && Array.isArray(endCoords) &&
|
||
startCoords.length >= 2 && endCoords.length >= 2) {
|
||
createLineMarker(
|
||
startCoords[0], startCoords[1],
|
||
endCoords[0], endCoords[1],
|
||
overlayElement,
|
||
Boolean(normStart && normEnd)
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 3. 4-coordinate array
|
||
const normCoords = act.normalized_coordinates || args.normalized_coordinates;
|
||
const isNorm = Boolean(normCoords);
|
||
const rawCoords = normCoords || act.coordinates || act.coords || act.target ||
|
||
args.target || args.coordinates || args.coords ||
|
||
(isSwipeLike && Array.isArray(args.action) ? args.action : null) ||
|
||
(isSwipeLike && Array.isArray(act.action) ? act.action : null) ||
|
||
(isSwipeLike && Array.isArray(args.gesture) ? args.gesture : null) ||
|
||
(isSwipeLike && Array.isArray(act.gesture) ? act.gesture : null);
|
||
|
||
if (rawCoords && Array.isArray(rawCoords)) {
|
||
if (rawCoords.length === 4 && typeof rawCoords[0] === 'number') {
|
||
createLineMarker(rawCoords[0], rawCoords[1], rawCoords[2], rawCoords[3], overlayElement, isNorm);
|
||
return;
|
||
} else if (rawCoords.length === 2 && typeof rawCoords[0] === 'number') {
|
||
if (!isSwipeLike) {
|
||
createPointMarker(rawCoords[0], rawCoords[1], actionName, overlayElement, isNorm);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. Directional swipe / scroll ONLY for swipe-like actions
|
||
if (isSwipeLike) {
|
||
const rawDir = (typeof args.action === 'string' ? args.action : null) ||
|
||
(typeof act.action === 'string' && act.action !== actionName ? act.action : null) ||
|
||
args.direction || act.direction || args.gesture || act.gesture || '';
|
||
const dirStr = String(rawDir).toLowerCase().trim();
|
||
|
||
let sx = 500, sy = 750, ex = 500, ey = 250;
|
||
if (dirStr.includes('up') || dirStr.includes('top') || actionName.includes('up')) {
|
||
sx = 500; sy = 750; ex = 500; ey = 250;
|
||
} else if (dirStr.includes('down') || dirStr.includes('bottom') || actionName.includes('down')) {
|
||
sx = 500; sy = 250; ex = 500; ey = 750;
|
||
} else if (dirStr.includes('left') || actionName.includes('left')) {
|
||
sx = 800; sy = 500; ex = 200; ey = 500;
|
||
} else if (dirStr.includes('right') || actionName.includes('right')) {
|
||
sx = 200; sy = 500; ex = 800; ey = 500;
|
||
}
|
||
createLineMarker(sx, sy, ex, ey, overlayElement, true);
|
||
return;
|
||
}
|
||
|
||
if (rawCoords && Array.isArray(rawCoords) && rawCoords.length === 2 && typeof rawCoords[0] === 'number') {
|
||
createPointMarker(rawCoords[0], rawCoords[1], actionName, overlayElement, isNorm);
|
||
}
|
||
});
|
||
}
|
||
|
||
function setupImageCoordinates(imgId, overlayId, actionObj) {
|
||
const img = document.getElementById(imgId);
|
||
const overlay = document.getElementById(overlayId);
|
||
if (!img || !overlay) return;
|
||
|
||
const draw = () => drawActionCoordinates(actionObj, overlay);
|
||
|
||
if (img.complete && img.naturalWidth > 0) {
|
||
draw();
|
||
} else {
|
||
img.onload = draw;
|
||
}
|
||
}
|
||
|
||
let currentSessionId = null;
|
||
let sessionsMap = {};
|
||
let isBackgroundTaskPanelOpen = false;
|
||
let currentBackgroundTasksData = [];
|
||
|
||
function toggleVideoContent(forceState) {
|
||
const vc = document.getElementById('video-content');
|
||
const icon = document.getElementById('video-toggle-btn-icon');
|
||
if (!vc) return;
|
||
const shouldExpand = forceState !== undefined ? forceState : (vc.style.display === 'none');
|
||
vc.style.display = shouldExpand ? 'block' : 'none';
|
||
if (icon) icon.textContent = shouldExpand ? '➖' : '➕';
|
||
}
|
||
|
||
function updateMiniVideoPlayer(videoUrl, expand = false) {
|
||
const container = document.getElementById('floating-video-container');
|
||
const globalVideo = document.getElementById('global-video');
|
||
const globalSrc = document.getElementById('global-video-src');
|
||
const errorBox = document.getElementById('video-error-box');
|
||
const errorMsg = document.getElementById('video-error-text');
|
||
const overlay = document.getElementById('global-video-overlay');
|
||
if (!container || !globalVideo) return;
|
||
|
||
globalVideo.onerror = function() {
|
||
globalVideo.style.display = 'none';
|
||
if (overlay) overlay.style.display = 'none';
|
||
if (errorBox) {
|
||
errorBox.style.display = 'block';
|
||
if (errorMsg) errorMsg.textContent = "Video file failed to play or was not found on server.";
|
||
}
|
||
};
|
||
|
||
if (videoUrl) {
|
||
globalVideo.style.display = 'block';
|
||
if (overlay) overlay.style.display = 'block';
|
||
if (errorBox) errorBox.style.display = 'none';
|
||
|
||
globalVideo.src = videoUrl;
|
||
if (globalSrc) globalSrc.src = videoUrl;
|
||
globalVideo.load();
|
||
container.style.display = 'block';
|
||
toggleVideoContent(expand);
|
||
} else if (currentSessionId) {
|
||
globalVideo.style.display = 'none';
|
||
if (overlay) overlay.style.display = 'none';
|
||
if (errorBox) {
|
||
errorBox.style.display = 'block';
|
||
if (errorMsg) errorMsg.textContent = "No video recording file was found for this session on disk or database records.";
|
||
}
|
||
globalVideo.pause();
|
||
container.style.display = 'block';
|
||
toggleVideoContent(expand);
|
||
} else {
|
||
container.style.display = 'none';
|
||
globalVideo.pause();
|
||
}
|
||
}
|
||
|
||
async function reloadSessionVideo() {
|
||
if (!currentSessionId) {
|
||
alert("Please select a session first.");
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/api/sessions');
|
||
const sessions = await res.json();
|
||
sessionsMap = {};
|
||
sessions.forEach(s => { sessionsMap[s.session_id] = s; });
|
||
const cur = sessionsMap[currentSessionId];
|
||
const vUrl = cur ? cur.video_url : null;
|
||
|
||
updateMiniVideoPlayer(vUrl, true);
|
||
} catch (err) {
|
||
console.error("Failed to reload video:", err);
|
||
}
|
||
}
|
||
|
||
function adjustContentPadding() {
|
||
const container = document.getElementById('global-task-container');
|
||
const content = document.getElementById('content');
|
||
if (container && container.style.display !== 'none') {
|
||
requestAnimationFrame(() => {
|
||
content.style.paddingBottom = (container.offsetHeight + 40) + 'px';
|
||
});
|
||
} else {
|
||
content.style.paddingBottom = '30px';
|
||
}
|
||
}
|
||
|
||
function toggleBackgroundTaskPanel() {
|
||
isBackgroundTaskPanelOpen = !isBackgroundTaskPanelOpen;
|
||
const panel = document.getElementById('global-task-panel');
|
||
const icon = document.getElementById('task-panel-toggle-icon');
|
||
if (isBackgroundTaskPanelOpen) {
|
||
panel.style.display = 'block';
|
||
icon.innerText = '▼ Collapse';
|
||
} else {
|
||
panel.style.display = 'none';
|
||
icon.innerText = '▲ Expand';
|
||
}
|
||
updateBackgroundTasksUI(currentBackgroundTasksData);
|
||
adjustContentPadding();
|
||
}
|
||
|
||
function formatBgStreamDisplayText(streamData) {
|
||
if (!streamData) return '';
|
||
if (typeof streamData === 'string') return streamData;
|
||
const text = streamData.text || '';
|
||
const thinking = streamData.thinking || '';
|
||
return thinking ? `[Thinking]\n${thinking}\n\n[Output]\n${text}` : text;
|
||
}
|
||
|
||
function updateBackgroundTaskStreamDisplay(targetId) {
|
||
// Only the streaming trace and the session aggregate change per chunk,
|
||
// so address their <pre> elements directly instead of scanning the document.
|
||
const candidateIds = new Set();
|
||
if (targetId) candidateIds.add(targetId);
|
||
if (currentSessionId) candidateIds.add(currentSessionId);
|
||
candidateIds.forEach(id => {
|
||
const pre = document.getElementById(`bg-stream-${id}`);
|
||
if (!pre) return;
|
||
const streamData = nodeStreams[id] || (id === currentSessionId ? nodeStreams[targetId] : null);
|
||
if (streamData) {
|
||
updateStreamPreContent(pre, formatBgStreamDisplayText(streamData));
|
||
scheduleStreamAutoScroll(pre);
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateBackgroundTasksUI(tasks) {
|
||
currentBackgroundTasksData = tasks || [];
|
||
const container = document.getElementById('global-task-container');
|
||
if (!tasks || tasks.length === 0) {
|
||
if (!container.style.display || container.style.display === 'none') {
|
||
container.style.display = 'none';
|
||
adjustContentPadding();
|
||
}
|
||
return;
|
||
}
|
||
|
||
container.style.display = 'flex';
|
||
|
||
const runningTasks = tasks.filter(t => t.status === 'running');
|
||
const completedTasks = tasks.filter(t => t.status === 'completed');
|
||
const barText = document.getElementById('task-bar-text');
|
||
const spinner = document.getElementById('task-bar-spinner');
|
||
|
||
if (runningTasks.length > 0) {
|
||
spinner.style.display = 'block';
|
||
barText.innerText = `Background Tasks (Running: ${runningTasks.length}, Completed: ${completedTasks.length}) - Latest: ${runningTasks[runningTasks.length - 1].summary}`;
|
||
} else {
|
||
spinner.style.display = 'none';
|
||
barText.innerText = `All background tasks completed (${tasks.length})`;
|
||
}
|
||
|
||
if (isBackgroundTaskPanelOpen) {
|
||
const list = document.getElementById('background-tasks-list');
|
||
const existingItems = {};
|
||
Array.from(list.children).forEach(child => {
|
||
if (child.dataset && child.dataset.taskKey) {
|
||
existingItems[child.dataset.taskKey] = child;
|
||
}
|
||
});
|
||
const seenKeys = new Set();
|
||
|
||
tasks.forEach((task, index) => {
|
||
const taskKey = String(task.trace_id || task.task_id || task.id || `idx-${index}`);
|
||
seenKeys.add(taskKey);
|
||
|
||
let statusColor = '#94a3b8';
|
||
let statusLabel = task.status;
|
||
if (task.status === 'running') { statusColor = '#d69e2e'; statusLabel = 'Running'; }
|
||
else if (task.status === 'completed') { statusColor = '#10b981'; statusLabel = 'Completed'; }
|
||
|
||
let durationStr = '';
|
||
let timeStr = '';
|
||
if (task.start_time) {
|
||
const end = task.end_time || (Date.now() / 1000);
|
||
durationStr = `${(end - task.start_time).toFixed(1)}s`;
|
||
timeStr = formatRelativeTime(task.start_time);
|
||
}
|
||
|
||
const existing = existingItems[taskKey];
|
||
if (existing) {
|
||
// Diff update: only touch changed text/status to preserve DOM and scroll state
|
||
const summaryEl = existing.querySelector('.bg-task-summary');
|
||
if (summaryEl && summaryEl.innerText !== task.summary) summaryEl.innerText = task.summary;
|
||
const timeEl = existing.querySelector('.bg-task-time');
|
||
if (timeEl && timeEl.innerText !== timeStr) timeEl.innerText = timeStr;
|
||
const statusEl = existing.querySelector('.bg-task-status');
|
||
if (statusEl) {
|
||
const statusText = `● ${statusLabel}`;
|
||
if (statusEl.innerText !== statusText) statusEl.innerText = statusText;
|
||
statusEl.style.color = statusColor;
|
||
}
|
||
const durationEl = existing.querySelector('.bg-task-duration');
|
||
if (durationEl) {
|
||
const durationText = durationStr ? `(${durationStr})` : '';
|
||
if (durationEl.innerText !== durationText) durationEl.innerText = durationText;
|
||
}
|
||
if (task.trace_id) {
|
||
const pre = document.getElementById(`bg-stream-${task.trace_id}`);
|
||
if (!pre) {
|
||
existing.appendChild(createBgTaskStreamContainer(task));
|
||
} else {
|
||
const displayText = formatBgStreamDisplayText(nodeStreams[task.trace_id]) || task.logs || 'Waiting for stream output...';
|
||
updateStreamPreContent(pre, displayText);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const item = document.createElement('div');
|
||
item.dataset.taskKey = taskKey;
|
||
item.style.backgroundColor = '#1f2937';
|
||
item.style.border = '1px solid #374151';
|
||
item.style.borderRadius = '6px';
|
||
item.style.padding = '12px';
|
||
|
||
item.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<span class="bg-task-summary" style="color: #f3f4f6; font-weight: 600; font-size: 0.9rem;">${escapeHtml(task.summary)}</span>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<span class="bg-task-time" style="color: #6b7280; font-size: 0.75rem;">${timeStr}</span>
|
||
<span class="bg-task-status" style="color: ${statusColor}; font-size: 0.8rem; font-weight: bold;">● ${escapeHtml(statusLabel)}</span>
|
||
<span class="bg-task-duration" style="color: #6b7280; font-size: 0.75rem;">${durationStr ? `(${durationStr})` : ''}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
if (task.trace_id) {
|
||
item.appendChild(createBgTaskStreamContainer(task));
|
||
}
|
||
|
||
list.appendChild(item);
|
||
});
|
||
|
||
// Remove items for tasks that no longer exist
|
||
Array.from(list.children).forEach(child => {
|
||
const key = child.dataset ? child.dataset.taskKey : null;
|
||
if (key && !seenKeys.has(key)) child.remove();
|
||
});
|
||
}
|
||
adjustContentPadding();
|
||
}
|
||
|
||
function createBgTaskStreamContainer(task) {
|
||
const streamContainer = document.createElement('div');
|
||
streamContainer.style.marginTop = '8px';
|
||
streamContainer.style.backgroundColor = '#111827';
|
||
streamContainer.style.borderRadius = '4px';
|
||
streamContainer.style.padding = '8px';
|
||
streamContainer.style.borderLeft = '2px solid #3b82f6';
|
||
|
||
const label = document.createElement('div');
|
||
label.style.fontSize = '0.7rem';
|
||
label.style.color = '#6b7280';
|
||
label.style.marginBottom = '4px';
|
||
label.innerText = 'Live LLM Stream:';
|
||
|
||
const pre = document.createElement('pre');
|
||
pre.id = `bg-stream-${task.trace_id}`;
|
||
pre.style.margin = '0';
|
||
pre.style.padding = '0';
|
||
pre.style.backgroundColor = 'transparent';
|
||
pre.style.color = '#d1d5db';
|
||
pre.style.fontSize = '0.8rem';
|
||
pre.style.maxHeight = '120px';
|
||
pre.style.overflowY = 'auto';
|
||
pre.style.whiteSpace = 'pre-wrap';
|
||
const displayText = formatBgStreamDisplayText(nodeStreams[task.trace_id]);
|
||
updateStreamPreContent(pre, displayText || task.logs || 'Waiting for stream output...');
|
||
|
||
streamContainer.appendChild(label);
|
||
streamContainer.appendChild(pre);
|
||
|
||
scheduleStreamAutoScroll(pre);
|
||
return streamContainer;
|
||
}
|
||
|
||
let activeNodeId = null;
|
||
let pollInterval = null;
|
||
let eventSource = null;
|
||
let activeNodeDetails = null;
|
||
let traceMap = {};
|
||
let nodeStreams = {};
|
||
|
||
// Cap for session-level aggregated stream text; keep only the tail beyond this size
|
||
const SESSION_STREAM_TEXT_CAP = 200000;
|
||
let nodeStreamsPersistTimer = null;
|
||
|
||
function trimStreamEntry(entry) {
|
||
if (!entry || typeof entry !== 'object') return;
|
||
if (entry.text && entry.text.length > SESSION_STREAM_TEXT_CAP) {
|
||
entry.text = entry.text.slice(-SESSION_STREAM_TEXT_CAP);
|
||
}
|
||
if (entry.thinking && entry.thinking.length > SESSION_STREAM_TEXT_CAP) {
|
||
entry.thinking = entry.thinking.slice(-SESSION_STREAM_TEXT_CAP);
|
||
}
|
||
}
|
||
|
||
function flushNodeStreamsPersist() {
|
||
if (nodeStreamsPersistTimer) {
|
||
clearTimeout(nodeStreamsPersistTimer);
|
||
nodeStreamsPersistTimer = null;
|
||
}
|
||
if (!currentSessionId) return;
|
||
try {
|
||
sessionStorage.setItem('nodeStreams_' + currentSessionId, JSON.stringify(nodeStreams));
|
||
} catch (e) {
|
||
// Ignore quota/serialization errors; persistence is best-effort
|
||
}
|
||
}
|
||
|
||
function scheduleNodeStreamsPersist() {
|
||
if (nodeStreamsPersistTimer) return;
|
||
nodeStreamsPersistTimer = setTimeout(() => {
|
||
nodeStreamsPersistTimer = null;
|
||
flushNodeStreamsPersist();
|
||
}, 2000);
|
||
}
|
||
|
||
async function fetchTaskPlan() {
|
||
if (!currentSessionId) return;
|
||
const contentPre = document.getElementById('plan-modal-content');
|
||
contentPre.innerText = 'Fetching latest task plan...';
|
||
try {
|
||
const res = await fetch(`/api/sessions/${currentSessionId}/plan`);
|
||
const data = await res.json();
|
||
contentPre.innerText = data.plan || 'No plan content returned.';
|
||
} catch (err) {
|
||
contentPre.innerText = `Error fetching plan: ${err.message}`;
|
||
}
|
||
}
|
||
|
||
function openPlanModal() {
|
||
document.getElementById('plan-modal').style.display = 'flex';
|
||
fetchTaskPlan();
|
||
}
|
||
|
||
function closePlanModal() {
|
||
document.getElementById('plan-modal').style.display = 'none';
|
||
}
|
||
|
||
async function fetchAllNotes() {
|
||
if (!currentSessionId) return;
|
||
const contentDiv = document.getElementById('notes-modal-content');
|
||
contentDiv.innerHTML = '<div style="color: #e2e8f0; font-family: monospace;">Fetching notes...</div>';
|
||
try {
|
||
const res = await fetch(`/api/sessions/${currentSessionId}/notes`);
|
||
const data = await res.json();
|
||
if (data.notes && Object.keys(data.notes).length > 0) {
|
||
let html = '';
|
||
for (const [filename, content] of Object.entries(data.notes)) {
|
||
html += `<h3 style="color: #38bdf8; margin-top: 0;">${escapeHtml(filename)}</h3>`;
|
||
html += `<pre style="margin: 0 0 20px 0; background-color: #0f172a; padding: 16px; border-radius: 8px; color: #e2e8f0; font-family: 'Courier New', Courier, monospace; font-size: 0.9rem; white-space: pre-wrap;">${escapeHtml(content)}</pre>`;
|
||
}
|
||
contentDiv.innerHTML = html;
|
||
} else {
|
||
contentDiv.innerHTML = '<div style="color: #e2e8f0; font-family: monospace;">No notes found for this session.</div>';
|
||
}
|
||
} catch (err) {
|
||
contentDiv.innerHTML = `<div style="color: #ef4444; font-family: monospace;">Error fetching notes: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function openNotesModal() {
|
||
document.getElementById('notes-modal').style.display = 'flex';
|
||
fetchAllNotes();
|
||
}
|
||
|
||
function closeNotesModal() {
|
||
document.getElementById('notes-modal').style.display = 'none';
|
||
}
|
||
|
||
async function resumeTask() {
|
||
const statusDiv = document.getElementById('run-status');
|
||
statusDiv.innerHTML = '<span style="color: #d69e2e;">Resuming task...</span>';
|
||
try {
|
||
const res = await fetch('/api/resume', { method: 'POST' });
|
||
if (res.ok) {
|
||
statusDiv.innerHTML = `<span style="color: #38a169;">Task resumed!</span>`;
|
||
document.getElementById('resume-btn').style.display = 'none';
|
||
} else {
|
||
const data = await res.json();
|
||
statusDiv.innerHTML = `<span style="color: #e53e3e;">Failed to resume: ${escapeHtml(data.detail || 'Unknown error')}</span>`;
|
||
}
|
||
} catch (err) {
|
||
statusDiv.innerHTML = `<span style="color: #e53e3e;">Error: ${escapeHtml(err.message)}</span>`;
|
||
}
|
||
}
|
||
|
||
async function stopTask() {
|
||
const statusDiv = document.getElementById('run-status');
|
||
statusDiv.innerHTML = '<span style="color: #d69e2e;">Stopping task...</span>';
|
||
try {
|
||
const res = await fetch('/api/stop', { method: 'POST' });
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
statusDiv.innerHTML = `<span style="color: #38a169;">Task stopped!</span>`;
|
||
setTimeout(loadSessions, 2000);
|
||
} else {
|
||
statusDiv.innerHTML = `<span style="color: #e53e3e;">Failed to stop: ${escapeHtml(data.detail || 'Unknown error')}</span>`;
|
||
}
|
||
} catch (err) {
|
||
statusDiv.innerHTML = `<span style="color: #e53e3e;">Error: ${escapeHtml(err.message)}</span>`;
|
||
}
|
||
}
|
||
|
||
async function runTask() {
|
||
const goal = document.getElementById('task-goal').value;
|
||
const profileElement = document.getElementById('task-profile-select');
|
||
const profile = profileElement ? profileElement.value : 'flash';
|
||
const statusDiv = document.getElementById('run-status');
|
||
if (!goal) {
|
||
statusDiv.innerHTML = '<span style="color: #e53e3e;">Please enter a goal</span>';
|
||
return;
|
||
}
|
||
statusDiv.innerHTML = '<span style="color: #d69e2e;">Starting task...</span>';
|
||
try {
|
||
const res = await fetch('/api/run', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ goal: goal, profile: profile })
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
statusDiv.innerHTML = `<span style="color: #38a169;">Task started!</span>`;
|
||
document.getElementById('task-goal').value = '';
|
||
setTimeout(loadSessions, 2000);
|
||
} else {
|
||
statusDiv.innerHTML = `<span style="color: #e53e3e;">Failed: ${escapeHtml(data.detail || 'Unknown error')}</span>`;
|
||
}
|
||
} catch (err) {
|
||
statusDiv.innerHTML = `<span style="color: #e53e3e;">Error: ${escapeHtml(err.message)}</span>`;
|
||
}
|
||
}
|
||
|
||
async function deleteSession(event, sessionId) {
|
||
event.stopPropagation();
|
||
if (!confirm('Are you sure you want to delete this session and all its files?')) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`/api/sessions/${sessionId}/delete`, { method: 'POST' });
|
||
if (res.ok) {
|
||
if (currentSessionId === sessionId) {
|
||
currentSessionId = null;
|
||
updateMiniVideoPlayer(null);
|
||
|
||
document.getElementById('session-header').innerHTML = '<h1>Select a session from the sidebar</h1>';
|
||
document.getElementById('session-tabs-bar').style.display = 'none';
|
||
document.getElementById('steps-container').style.display = 'none';
|
||
document.getElementById('traces-container-wrapper').style.display = 'none';
|
||
document.getElementById('replay-container-wrapper').style.display = 'none';
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
}
|
||
loadSessions();
|
||
} else {
|
||
const data = await res.json();
|
||
alert(`Failed to delete session: ${data.detail || 'Unknown error'}`);
|
||
}
|
||
} catch (err) {
|
||
alert(`Error: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
let allSessionsData = [];
|
||
|
||
async function loadSessions() {
|
||
try {
|
||
const res = await fetch('/api/sessions');
|
||
allSessionsData = await res.json();
|
||
filterSessionsList();
|
||
} catch (err) {
|
||
document.getElementById('session-list').innerText = 'Failed to load sessions';
|
||
}
|
||
}
|
||
|
||
function filterSessionsList() {
|
||
const input = document.getElementById('session-search-input');
|
||
const query = input ? input.value.trim().toLowerCase() : '';
|
||
|
||
const filtered = allSessionsData.filter(session => {
|
||
if (!query) return true;
|
||
const sid = (session.session_id || '').toLowerCase();
|
||
const goal = (session.initial_goal || '').toLowerCase();
|
||
return sid.includes(query) || goal.includes(query);
|
||
});
|
||
|
||
renderSessionsList(filtered);
|
||
}
|
||
|
||
function renderSessionsList(sessions) {
|
||
const list = document.getElementById('session-list');
|
||
if (!list) return;
|
||
list.innerHTML = '';
|
||
sessionsMap = {};
|
||
|
||
if (sessions.length === 0) {
|
||
list.innerHTML = '<div style="padding: 15px; color: #718096; text-align: center; font-size: 0.85rem;">No matching sessions found.</div>';
|
||
return;
|
||
}
|
||
|
||
sessions.forEach(session => {
|
||
sessionsMap[session.session_id] = session;
|
||
const div = document.createElement('div');
|
||
div.className = `session-item ${session.session_id === currentSessionId ? 'active' : ''}`;
|
||
div.onclick = () => selectSession(session.session_id, session.initial_goal, session.start_time);
|
||
|
||
const time = new Date(session.start_time * 1000).toLocaleString();
|
||
const titleText = session.initial_goal || 'Unnamed Session';
|
||
const sidDisplay = session.session_id || '';
|
||
const deviceSerial = session.device_serial || session.device_id || '';
|
||
const devicePrefix = deviceSerial ? `${escapeHtml(deviceSerial)} · ` : '';
|
||
|
||
div.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||
<div style="flex: 1; min-width: 0;">
|
||
<h3 style="font-size: 0.95rem; margin-bottom: 3px; margin-top: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(titleText)}">${escapeHtml(titleText)}</h3>
|
||
<div style="font-size: 0.75rem; font-family: monospace; opacity: 0.85; margin-bottom: 3px; word-break: break-all; color: ${session.session_id === currentSessionId ? '#edf2f7' : '#4a5568'};">ID: ${escapeHtml(sidDisplay)}</div>
|
||
<small style="color: ${session.session_id === currentSessionId ? '#e2e8f0' : '#718096'}; font-size: 0.75rem;">${devicePrefix}${time}</small>
|
||
</div>
|
||
<button onclick="deleteSession(event, '${escapeHtml(session.session_id)}')" style="background: none; border: none; color: ${session.session_id === currentSessionId ? '#fff' : '#e53e3e'}; cursor: pointer; font-size: 1.1rem; padding: 0 0 0 8px; line-height: 1; flex-shrink: 0;" title="Delete Session">🗑️</button>
|
||
</div>
|
||
`;
|
||
list.appendChild(div);
|
||
});
|
||
}
|
||
|
||
let sessionStartTime = null;
|
||
let activeTab = 'steps';
|
||
|
||
function switchTab(tabName) {
|
||
activeTab = tabName;
|
||
|
||
// Update tab button active states
|
||
const buttons = document.querySelectorAll('.tab-btn');
|
||
buttons.forEach(btn => {
|
||
if (btn.getAttribute('onclick').includes(tabName)) {
|
||
btn.classList.add('active');
|
||
btn.style.borderBottom = '2px solid #3182ce';
|
||
btn.style.color = '#3182ce';
|
||
} else {
|
||
btn.classList.remove('active');
|
||
btn.style.borderBottom = 'none';
|
||
btn.style.color = '#718096';
|
||
}
|
||
});
|
||
|
||
const stepsContainer = document.getElementById('steps-container');
|
||
const tracesWrapper = document.getElementById('traces-container-wrapper');
|
||
const replayWrapper = document.getElementById('replay-container-wrapper');
|
||
|
||
if (tabName === 'steps') {
|
||
stepsContainer.style.display = 'block';
|
||
tracesWrapper.style.display = 'none';
|
||
replayWrapper.style.display = 'none';
|
||
loadSteps();
|
||
} else if (tabName === 'traces') {
|
||
stepsContainer.style.display = 'none';
|
||
tracesWrapper.style.display = 'block';
|
||
replayWrapper.style.display = 'none';
|
||
loadTree();
|
||
} else if (tabName === 'replay') {
|
||
stepsContainer.style.display = 'none';
|
||
tracesWrapper.style.display = 'none';
|
||
replayWrapper.style.display = 'block';
|
||
loadReplaySteps();
|
||
}
|
||
}
|
||
|
||
const userExpandedStepIds = new Set();
|
||
|
||
function onStepDetailsToggle(detailsEl, stepId) {
|
||
if (!detailsEl) return;
|
||
if (detailsEl.open) {
|
||
userExpandedStepIds.add(stepId);
|
||
loadStepTraces(stepId, detailsEl);
|
||
} else {
|
||
userExpandedStepIds.delete(stepId);
|
||
}
|
||
}
|
||
|
||
function selectSession(sessionId, goal, startTime) {
|
||
// Flush any pending stream persistence for the previous session before switching
|
||
flushNodeStreamsPersist();
|
||
currentSessionId = sessionId;
|
||
sessionStartTime = startTime;
|
||
userExpandedStepIds.clear();
|
||
stepTracesCache = {};
|
||
stepFilterMap = {};
|
||
document.getElementById('session-header').innerHTML = `<h1>${escapeHtml(goal || 'Session Details')}</h1><p><small>ID: ${escapeHtml(sessionId)}</small></p>`;
|
||
|
||
|
||
|
||
const sessionObj = sessionsMap[sessionId];
|
||
updateMiniVideoPlayer(sessionObj ? sessionObj.video_url : null);
|
||
|
||
// Make session tabs bar visible
|
||
document.getElementById('session-tabs-bar').style.display = 'flex';
|
||
|
||
// Set correct display states based on active tab
|
||
const stepsContainer = document.getElementById('steps-container');
|
||
const tracesWrapper = document.getElementById('traces-container-wrapper');
|
||
const replayWrapper = document.getElementById('replay-container-wrapper');
|
||
|
||
stepsContainer.style.display = activeTab === 'steps' ? 'block' : 'none';
|
||
tracesWrapper.style.display = activeTab === 'traces' ? 'block' : 'none';
|
||
replayWrapper.style.display = activeTab === 'replay' ? 'block' : 'none';
|
||
|
||
loadSessions(); // Refresh list to update active class
|
||
|
||
// Attempt to recover persisted stream data from sessionStorage to survive browser reloads
|
||
const savedStreams = sessionStorage.getItem('nodeStreams_' + sessionId);
|
||
if (savedStreams) {
|
||
try {
|
||
nodeStreams = JSON.parse(savedStreams);
|
||
} catch(e) {
|
||
nodeStreams = {};
|
||
}
|
||
} else {
|
||
nodeStreams = {};
|
||
}
|
||
|
||
if (activeTab === 'steps') {
|
||
loadSteps();
|
||
} else if (activeTab === 'traces') {
|
||
loadTree();
|
||
} else if (activeTab === 'replay') {
|
||
loadReplaySteps();
|
||
}
|
||
|
||
// Connect to SSE
|
||
if (eventSource) eventSource.close();
|
||
eventSource = new EventSource(`/api/stream/${sessionId}`);
|
||
|
||
eventSource.addEventListener('llm_stream', function(e) {
|
||
const data = JSON.parse(e.data);
|
||
const traceId = data.execution_id;
|
||
|
||
if (!nodeStreams[traceId]) {
|
||
nodeStreams[traceId] = { text: '', thinking: '', parentTraceId: data.parent_trace_id };
|
||
} else if (typeof nodeStreams[traceId] === 'string') {
|
||
nodeStreams[traceId] = { text: nodeStreams[traceId], thinking: '', parentTraceId: data.parent_trace_id };
|
||
} else {
|
||
nodeStreams[traceId].parentTraceId = data.parent_trace_id || nodeStreams[traceId].parentTraceId;
|
||
}
|
||
|
||
if (data.is_thinking) {
|
||
nodeStreams[traceId].thinking += data.chunk;
|
||
} else {
|
||
nodeStreams[traceId].text += data.chunk;
|
||
}
|
||
|
||
const sessionId = data.session_id || currentSessionId;
|
||
if (sessionId) {
|
||
if (!nodeStreams[sessionId]) {
|
||
nodeStreams[sessionId] = { text: '', thinking: '' };
|
||
} else if (typeof nodeStreams[sessionId] === 'string') {
|
||
nodeStreams[sessionId] = { text: nodeStreams[sessionId], thinking: '' };
|
||
}
|
||
if (data.is_thinking) {
|
||
nodeStreams[sessionId].thinking += data.chunk;
|
||
} else {
|
||
nodeStreams[sessionId].text += data.chunk;
|
||
}
|
||
trimStreamEntry(nodeStreams[sessionId]);
|
||
}
|
||
|
||
scheduleNodeStreamsPersist();
|
||
|
||
updateStreamDisplay(traceId);
|
||
updateBackgroundTaskStreamDisplay(traceId);
|
||
});
|
||
|
||
eventSource.addEventListener('trace_recorded', function(e) {
|
||
const data = JSON.parse(e.data);
|
||
if (data.trace_id && data.name) {
|
||
traceMap[data.trace_id] = data.name;
|
||
}
|
||
if (data.status && data.status !== 'running') {
|
||
if (nodeStreams[data.trace_id]) delete nodeStreams[data.trace_id];
|
||
const finishedNode = document.getElementById(`node-div-${data.trace_id}`);
|
||
if (finishedNode) {
|
||
finishedNode.querySelectorAll('.node-stream-container').forEach(el => el.remove());
|
||
}
|
||
}
|
||
scheduleDebouncedReload();
|
||
});
|
||
|
||
eventSource.addEventListener('step_recorded', function(e) {
|
||
scheduleDebouncedReload();
|
||
});
|
||
|
||
eventSource.addEventListener('step_updated', function(e) {
|
||
scheduleDebouncedReload();
|
||
});
|
||
|
||
eventSource.addEventListener('background_tasks_updated', function(e) {
|
||
const tasks = JSON.parse(e.data);
|
||
updateBackgroundTasksUI(tasks);
|
||
});
|
||
|
||
eventSource.addEventListener('task_paused', function(e) {
|
||
const data = JSON.parse(e.data);
|
||
document.getElementById('run-status').innerHTML = `<span style="color: #e53e3e;">Task Paused (LLM Error): ${escapeHtml(data.error)}</span>`;
|
||
document.getElementById('resume-btn').style.display = 'block';
|
||
});
|
||
|
||
eventSource.addEventListener('task_resumed', function(e) {
|
||
document.getElementById('run-status').innerHTML = `<span style="color: #38a169;">Task resumed!</span>`;
|
||
document.getElementById('resume-btn').style.display = 'none';
|
||
});
|
||
|
||
if (pollInterval) clearInterval(pollInterval);
|
||
}
|
||
|
||
|
||
// --- Step Replay Tab Logic ---
|
||
|
||
let replaySteps = [];
|
||
let replayConfig = [];
|
||
let activeReplayNodeId = null;
|
||
let activeReplayNodeDetails = null;
|
||
const replayInProgressSteps = new Set();
|
||
|
||
async function fetchDevices() {
|
||
try {
|
||
const res = await fetch(`/api/devices?_t=${Date.now()}`);
|
||
const devices = await res.json();
|
||
const select = document.getElementById('replay-device-select');
|
||
select.innerHTML = '';
|
||
|
||
if (devices.length === 0) {
|
||
select.innerHTML = '<option value="">No devices detected</option>';
|
||
return;
|
||
}
|
||
|
||
devices.forEach(d => {
|
||
const opt = document.createElement('option');
|
||
opt.value = d.serial;
|
||
opt.innerText = `${d.serial} (${d.status})`;
|
||
select.appendChild(opt);
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to fetch devices:", err);
|
||
}
|
||
}
|
||
|
||
let replayTools = [];
|
||
|
||
async function fetchReplayTools() {
|
||
try {
|
||
const res = await fetch(`/api/replay/tools?_t=${Date.now()}`);
|
||
replayTools = await res.json();
|
||
} catch (err) {
|
||
console.error("Failed to fetch replay tools:", err);
|
||
replayTools = [];
|
||
}
|
||
}
|
||
|
||
async function changeReplayTool(stepNum) {
|
||
const toolSelect = document.getElementById(`replay-tool-select-${stepNum}`);
|
||
if (!toolSelect) return;
|
||
const toolName = toolSelect.value;
|
||
const form = document.getElementById(`replay-params-form-${stepNum}`);
|
||
if (!form) return;
|
||
|
||
form.innerHTML = '<div style="grid-column: span 2; color: #64748b; font-style: italic; font-size: 0.8rem; padding: 10px 0;">Loading parameters...</div>';
|
||
|
||
try {
|
||
const res = await fetch(`/api/replay/config?tool_name=${encodeURIComponent(toolName)}&_t=${Date.now()}`);
|
||
if (!res.ok) throw new Error(await res.text());
|
||
const params = await res.json();
|
||
|
||
form.innerHTML = '';
|
||
|
||
// Get the step data to populate defaults
|
||
const step = replaySteps.find(s => s.step_number === stepNum);
|
||
|
||
if (params.length === 0) {
|
||
form.innerHTML = '<div style="grid-column: span 2; color: #64748b; font-style: italic; font-size: 0.8rem; padding: 10px 0;">No parameters required for this tool.</div>';
|
||
loadCachedReplayForStep(stepNum);
|
||
return;
|
||
}
|
||
|
||
params.forEach(param => {
|
||
const val = (step && step.param_defaults && step.param_defaults[toolName] && step.param_defaults[toolName][param.name]) || param.default || '';
|
||
const labelText = param.name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||
|
||
const group = document.createElement('div');
|
||
group.style.display = 'flex';
|
||
group.style.flexDirection = 'column';
|
||
group.style.gap = '5px';
|
||
group.innerHTML = `<label style="font-size: 0.8rem; font-weight: 600; color: #475569;">${escapeHtml(labelText)}</label>`;
|
||
|
||
if (param.input_type === 'textarea') {
|
||
const textarea = document.createElement('textarea');
|
||
textarea.id = `replay-input-${param.name}-${stepNum}`;
|
||
textarea.value = val;
|
||
textarea.placeholder = `Optional ${labelText}...`;
|
||
textarea.style.cssText = 'padding: 8px 10px; border-radius: 6px; border: 1px solid #cbd5e0; font-family: inherit; font-size: 0.85rem; height: 60px; resize: vertical; outline: none;';
|
||
group.appendChild(textarea);
|
||
} else if (param.input_type === 'checkbox') {
|
||
const checkboxContainer = document.createElement('div');
|
||
checkboxContainer.style.display = 'flex';
|
||
checkboxContainer.style.alignItems = 'center';
|
||
checkboxContainer.style.gap = '8px';
|
||
checkboxContainer.style.marginTop = '5px';
|
||
|
||
const checkbox = document.createElement('input');
|
||
checkbox.id = `replay-input-${param.name}-${stepNum}`;
|
||
checkbox.type = 'checkbox';
|
||
checkbox.checked = !!val;
|
||
checkbox.style.cursor = 'pointer';
|
||
|
||
const label = document.createElement('label');
|
||
label.htmlFor = checkbox.id;
|
||
label.style.fontSize = '0.8rem';
|
||
label.style.color = '#475569';
|
||
label.innerText = 'Enable';
|
||
|
||
checkboxContainer.appendChild(checkbox);
|
||
checkboxContainer.appendChild(label);
|
||
group.appendChild(checkboxContainer);
|
||
} else {
|
||
const input = document.createElement('input');
|
||
input.id = `replay-input-${param.name}-${stepNum}`;
|
||
input.type = param.input_type || 'text';
|
||
input.value = val;
|
||
input.placeholder = `Enter ${labelText}...`;
|
||
input.style.cssText = 'padding: 8px 10px; border-radius: 6px; border: 1px solid #cbd5e0; font-family: inherit; font-size: 0.85rem; outline: none;';
|
||
group.appendChild(input);
|
||
}
|
||
form.appendChild(group);
|
||
});
|
||
} catch (err) {
|
||
form.innerHTML = `<div style="grid-column: span 2; color: #dc2626; font-size: 0.8rem;">Failed to load parameters: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
|
||
// Reload cached replay traces for the selected tool
|
||
loadCachedReplayForStep(stepNum);
|
||
}
|
||
|
||
async function loadReplaySteps(preserveScroll = false) {
|
||
if (!currentSessionId) return;
|
||
|
||
const wrapper = document.getElementById('replay-container-wrapper');
|
||
const savedScrollTop = preserveScroll && wrapper ? wrapper.scrollTop : 0;
|
||
|
||
// Capture currently active step nav card if any
|
||
let activeStepNum = null;
|
||
const activeNavCard = document.querySelector('.step-nav-card.active');
|
||
if (activeNavCard) {
|
||
activeStepNum = parseInt(activeNavCard.id.split('-').pop());
|
||
}
|
||
|
||
const container = document.getElementById('replay-timeline-container');
|
||
container.innerHTML = '<div style="padding: 30px; color: #718096; text-align: center; font-size: 1rem;"><div class="spinner" style="width: 20px; height: 20px; border: 2px solid #3182ce; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; display: inline-block; margin-right: 8px; vertical-align: middle;"></div>Loading replay timeline...</div>';
|
||
|
||
const navContainer = document.getElementById('replay-steps-nav');
|
||
if (navContainer) {
|
||
navContainer.innerHTML = '';
|
||
navContainer.style.display = 'none';
|
||
}
|
||
|
||
// Fetch registered tools
|
||
if (replayTools.length === 0) {
|
||
await fetchReplayTools();
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/sessions/${currentSessionId}/replay_steps?_t=${Date.now()}`);
|
||
if (!res.ok) throw new Error(await res.text());
|
||
replaySteps = await res.json();
|
||
|
||
if (replaySteps.length === 0) {
|
||
container.innerHTML = '<div style="padding: 40px; color: #718096; text-align: center; font-size: 1.1rem;">🔄 No steps recorded yet for this session.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = '';
|
||
if (navContainer) {
|
||
navContainer.style.display = 'flex';
|
||
}
|
||
|
||
replaySteps.forEach(step => {
|
||
const hasPre = step.pre_screenshot && step.pre_screenshot !== 'None' && step.pre_screenshot !== 'null' && String(step.pre_screenshot).trim() !== '';
|
||
let hasPost = step.post_screenshot && step.post_screenshot !== 'None' && step.post_screenshot !== 'null' && String(step.post_screenshot).trim() !== '';
|
||
if (hasPre && hasPost && step.pre_screenshot === step.post_screenshot) {
|
||
hasPost = false;
|
||
}
|
||
const hasImages = hasPre || hasPost;
|
||
const stepNum = step.step_number;
|
||
|
||
// Create horizontal nav card
|
||
if (navContainer) {
|
||
const navCard = document.createElement('div');
|
||
navCard.className = 'step-nav-card';
|
||
navCard.id = `step-nav-card-${stepNum}`;
|
||
|
||
const navBorderColor = step.is_replayed ? '#a7f3d0' : '#e2e8f0';
|
||
const navBgColor = step.is_replayed ? '#f0fdf4' : 'white';
|
||
navCard.style.cssText = `flex: 0 0 auto; min-width: 140px; max-width: 200px; background: ${navBgColor}; border: 1.5px solid ${navBorderColor}; border-radius: 8px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; cursor: pointer; transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s, transform 0.2s; box-shadow: 0 1px 2px rgba(0,0,0,0.02); user-select: none;`;
|
||
|
||
if (activeStepNum === stepNum) {
|
||
navCard.classList.add('active');
|
||
navCard.style.borderColor = '#3182ce';
|
||
navCard.style.backgroundColor = step.is_replayed ? '#ecfdf5' : '#ebf8ff';
|
||
navCard.style.boxShadow = '0 2px 4px rgba(49, 130, 206, 0.1)';
|
||
}
|
||
|
||
navCard.onmouseover = () => {
|
||
if (!navCard.classList.contains('active')) {
|
||
navCard.style.borderColor = step.is_replayed ? '#86efac' : '#cbd5e0';
|
||
navCard.style.transform = 'translateY(-1px)';
|
||
navCard.style.boxShadow = '0 3px 6px rgba(0,0,0,0.05)';
|
||
}
|
||
};
|
||
navCard.onmouseout = () => {
|
||
if (!navCard.classList.contains('active')) {
|
||
navCard.style.borderColor = navBorderColor;
|
||
navCard.style.transform = 'translateY(0)';
|
||
navCard.style.boxShadow = '0 1px 2px rgba(0,0,0,0.02)';
|
||
}
|
||
};
|
||
|
||
navCard.onclick = () => {
|
||
document.querySelectorAll('.step-nav-card').forEach(c => {
|
||
c.classList.remove('active');
|
||
const isCardReplayed = replaySteps.find(s => `step-nav-card-${s.step_number}` === c.id)?.is_replayed;
|
||
c.style.borderColor = isCardReplayed ? '#a7f3d0' : '#e2e8f0';
|
||
c.style.backgroundColor = isCardReplayed ? '#f0fdf4' : 'white';
|
||
c.style.boxShadow = '0 1px 2px rgba(0,0,0,0.02)';
|
||
});
|
||
navCard.classList.add('active');
|
||
navCard.style.borderColor = '#3182ce';
|
||
navCard.style.backgroundColor = step.is_replayed ? '#ecfdf5' : '#ebf8ff';
|
||
navCard.style.boxShadow = '0 2px 4px rgba(49, 130, 206, 0.1)';
|
||
|
||
const targetCard = document.getElementById(`replay-step-card-${stepNum}`);
|
||
if (targetCard) {
|
||
targetCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
};
|
||
|
||
navCard.innerHTML = `
|
||
<span style="background-color: ${step.is_replayed ? '#10b981' : '#3182ce'}; color: white; border-radius: 50%; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: bold; flex-shrink: 0;">
|
||
${step.is_replayed ? '✓' : stepNum}
|
||
</span>
|
||
<div style="display: flex; flex-direction: column; overflow: hidden; flex: 1;">
|
||
<span style="font-size: 0.8rem; font-weight: 700; color: #1e293b; display: flex; align-items: center; justify-content: space-between; gap: 4px;">
|
||
<span>Step ${stepNum}</span>
|
||
${step.is_replayed ? '<span style="font-size: 0.65rem; color: #10b981; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">Replayed</span>' : ''}
|
||
</span>
|
||
<span style="font-size: 0.7rem; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(step.summary)}">${escapeHtml(step.summary || 'Executing...')}</span>
|
||
</div>
|
||
`;
|
||
navContainer.appendChild(navCard);
|
||
}
|
||
|
||
const stepCard = document.createElement('div');
|
||
stepCard.className = 'step-card';
|
||
stepCard.id = `replay-step-card-${stepNum}`;
|
||
const borderStyle = step.is_replayed
|
||
? 'border: 1px solid #10b981; border-left: 5px solid #10b981;'
|
||
: 'border: 1px solid #e2e8f0;';
|
||
stepCard.style.cssText = `background: white; ${borderStyle} border-radius: 8px; margin-bottom: 30px; box-shadow: 0 4px 6px rgba(0,0,0,0.03); overflow: hidden;`;
|
||
|
||
// Header HTML
|
||
const headerHtml = `
|
||
<div style="background-color: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center;">
|
||
<h3 style="margin: 0; color: #1e293b; font-size: 1.05rem; display: flex; align-items: center; gap: 10px;">
|
||
<span style="background-color: #3182ce; color: white; border-radius: 50%; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: bold;">${stepNum}</span>
|
||
<span>${escapeHtml(step.summary || 'Step Executing...')}</span>
|
||
${step.is_replayed ? '<span style="font-size: 0.65rem; background-color: #10b981; color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold; text-transform: uppercase; margin-left: 10px;">Replayed</span>' : ''}
|
||
</h3>
|
||
<span style="color: #64748b; font-size: 0.85rem; font-weight: 500;">${formatRelativeTime(step.timestamp)}</span>
|
||
</div>
|
||
`;
|
||
|
||
// Content panel grid
|
||
let contentHtml = `<div style="padding: 20px; display: flex; flex-direction: column; gap: 20px;">`;
|
||
|
||
// Screenshots comparison (if available)
|
||
if (hasImages) {
|
||
contentHtml += `
|
||
<div style="display: grid; grid-template-columns: ${(hasPre && hasPost) ? '1fr 1fr' : '1fr'}; gap: 20px; max-width: ${(hasPre && hasPost) ? '100%' : '360px'}; margin: 0 auto; width: 100%;">
|
||
`;
|
||
if (hasPre) {
|
||
const preSrc = getSafeLocalUrl(step.pre_screenshot);
|
||
contentHtml += `
|
||
<div style="text-align: center;">
|
||
<span style="font-size: 0.75rem; font-weight: 600; color: #64748b; display: block; margin-bottom: 6px;">${hasPost ? 'Pre-Action (Before)' : 'Screenshot'}</span>
|
||
<div class="image-wrapper" style="border: 1px solid #e2e8f0; border-radius: 6px; background-color: #f8fafc; cursor: zoom-in; max-height: 250px; overflow: hidden; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 3px rgba(0,0,0,0.03);" onclick="zoomImage('${escapeHtml(step.pre_screenshot)}')">
|
||
<div style="position: relative; display: inline-block; max-width: 100%;">
|
||
<img id="replay-img-${stepNum}" src="${escapeHtml(preSrc)}" style="max-height: 240px; max-width: 100%; object-fit: contain; display: block; margin: 0 auto;"/>
|
||
<div id="replay-overlay-${stepNum}" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (hasPost) {
|
||
const postSrc = getSafeLocalUrl(step.post_screenshot);
|
||
contentHtml += `
|
||
<div style="text-align: center;">
|
||
<span style="font-size: 0.75rem; font-weight: 600; color: #64748b; display: block; margin-bottom: 6px;">Post-Action (After)</span>
|
||
<div class="image-wrapper" style="border: 1px solid #e2e8f0; border-radius: 6px; background-color: #f8fafc; cursor: zoom-in; max-height: 250px; overflow: hidden; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 3px rgba(0,0,0,0.03);">
|
||
<img src="${escapeHtml(postSrc)}" style="max-height: 240px; max-width: 100%; object-fit: contain;" onclick="zoomImage('${escapeHtml(step.post_screenshot)}')"/>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
contentHtml += `</div>`;
|
||
}
|
||
|
||
// Main workspace grid (Strictly Top-Down Layout)
|
||
contentHtml += `
|
||
<div style="display: flex; flex-direction: column; gap: 25px; margin-top: 15px;">
|
||
<!-- 1. Original Traces Tree -->
|
||
<div class="detail-section" style="background-color: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 18px; display: flex; flex-direction: column; max-height: 500px; margin-bottom: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.02);">
|
||
<h3 style="margin-top: 0; border-bottom: 2px solid #edf2f7; padding-bottom: 10px; color: #475569; font-size: 0.95rem; text-transform: uppercase; letter-spacing: 0.05em; display: flex; align-items: center; gap: 6px;">
|
||
<span>🌳 Original Traces Tree</span>
|
||
</h3>
|
||
<div id="replay-original-tree-${stepNum}" style="flex: 1; overflow-y: auto;">
|
||
<div style="color: #94a3b8; font-style: italic; text-align: center; padding: 30px 0;">Loading original traces...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. Replay Configuration & Parameters -->
|
||
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 18px; display: flex; flex-direction: column; gap: 15px; box-shadow: 0 1px 2px rgba(0,0,0,0.01);">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf2f7; padding-bottom: 8px;">
|
||
<h4 style="margin: 0; font-size: 0.85rem; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: none; padding-bottom: 0;">⚙️ Replay Configuration</h4>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<label for="replay-tool-select-${stepNum}" style="font-size: 0.82rem; font-weight: 600; color: #475569;">Tool for Replay:</label>
|
||
<select id="replay-tool-select-${stepNum}" onchange="changeReplayTool(${stepNum})" style="padding: 4px 10px; border-radius: 6px; border: 1px solid #cbd5e0; font-size: 0.8rem; background-color: white; color: #334155; outline: none; cursor: pointer; font-family: inherit;">
|
||
<!-- Tool options dynamically populated -->
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div id="replay-params-form-${stepNum}" style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||
<!-- Parameter inputs dynamically created below -->
|
||
</div>
|
||
|
||
<!-- Execution Controls (Integrated at the bottom of parameters) -->
|
||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 12px; border-top: 1px solid #e2e8f0; margin-top: 5px;">
|
||
<button class="refresh-btn" id="btn-execute-replay-${stepNum}" style="margin-bottom: 0; background-color: #38a169; display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px;" onclick="runReplay(${stepNum})">
|
||
<span>▶ Run Live Replay</span>
|
||
</button>
|
||
<span id="replay-status-${stepNum}" style="font-size: 0.82rem; color: #64748b; font-style: italic; text-align: right;">Ready</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. Live Replay Traces Tree -->
|
||
<div class="detail-section" style="background-color: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 18px; display: flex; flex-direction: column; max-height: 500px; margin-bottom: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.02); transition: border-color 0.3s;" id="replay-live-tree-panel-${stepNum}">
|
||
<h3 style="margin-top: 0; border-bottom: 2px solid #edf2f7; padding-bottom: 10px; color: #38a169; font-size: 0.95rem; text-transform: uppercase; letter-spacing: 0.05em; display: flex; align-items: center; justify-content: space-between; gap: 6px; width: 100%;">
|
||
<span>⚡ Live Replay Traces Tree</span>
|
||
<span id="replay-id-display-${stepNum}" style="font-size: 0.75rem; color: #64748b; font-weight: normal; background: #f1f5f9; padding: 2px 8px; border-radius: 4px; text-transform: none; display: none;"></span>
|
||
</h3>
|
||
<div id="replay-live-tree-${stepNum}" style="flex: 1; overflow-y: auto;">
|
||
<div style="color: #94a3b8; font-style: italic; text-align: center; padding: 30px 0;" id="replay-live-tree-placeholder-${stepNum}">Run a live replay to populate replayed traces tree.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
contentHtml += `</div>`; // End Card Content
|
||
|
||
stepCard.innerHTML = headerHtml + contentHtml;
|
||
container.appendChild(stepCard);
|
||
|
||
// Populate tool dropdown and trigger dynamic parameters load
|
||
const toolSelect = document.getElementById(`replay-tool-select-${stepNum}`);
|
||
if (toolSelect && replayTools.length > 0) {
|
||
toolSelect.innerHTML = '';
|
||
replayTools.forEach(t => {
|
||
const opt = document.createElement('option');
|
||
opt.value = t.name;
|
||
opt.innerText = t.display_name;
|
||
opt.title = t.description;
|
||
toolSelect.appendChild(opt);
|
||
});
|
||
toolSelect.value = replayTools[0].name;
|
||
}
|
||
changeReplayTool(stepNum);
|
||
|
||
// Load original traces tree immediately
|
||
loadOriginalTreeForStep(stepNum, step.step_id);
|
||
|
||
if (hasPre && step.action_taken) {
|
||
setupImageCoordinates(`replay-img-${stepNum}`, `replay-overlay-${stepNum}`, step.action_taken);
|
||
}
|
||
});
|
||
|
||
// Restore scroll position if requested
|
||
if (preserveScroll && wrapper) {
|
||
setTimeout(() => {
|
||
wrapper.scrollTop = savedScrollTop;
|
||
}, 50);
|
||
}
|
||
} catch (err) {
|
||
container.innerHTML = `<div style="color: #e53e3e; padding: 20px; text-align: center; font-weight: bold;">Error: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function getSafeLocalUrl(url) {
|
||
if (!url || url === 'None' || url === 'null' || url === 'undefined') return '';
|
||
let targetUrl = String(url).trim();
|
||
if (!targetUrl || targetUrl === 'None' || targetUrl === 'null' || targetUrl === 'undefined') return '';
|
||
if (targetUrl.startsWith('/') && !targetUrl.startsWith('/local_file') && !targetUrl.startsWith('/images')) {
|
||
targetUrl = 'file://' + targetUrl;
|
||
}
|
||
if (targetUrl.startsWith('file://')) {
|
||
return '/local_file?path=' + encodeURIComponent(targetUrl);
|
||
}
|
||
return targetUrl;
|
||
}
|
||
|
||
async function loadOriginalTreeForStep(stepNum, stepId) {
|
||
const container = document.getElementById(`replay-original-tree-${stepNum}`);
|
||
try {
|
||
const res = await fetch(`/api/steps/${stepId}/traces`);
|
||
if (!res.ok) throw new Error(await res.text());
|
||
const treeData = await res.json();
|
||
renderOriginalReplayTreeForStep(stepNum, treeData);
|
||
} catch (err) {
|
||
container.innerHTML = `<div style="color: #e53e3e; padding: 10px; font-size: 0.85rem;">Failed to load original traces: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadCachedReplayForStep(stepNum) {
|
||
const container = document.getElementById(`replay-live-tree-${stepNum}`);
|
||
const panel = document.getElementById(`replay-live-tree-panel-${stepNum}`);
|
||
const toolSelect = document.getElementById(`replay-tool-select-${stepNum}`);
|
||
const toolName = toolSelect ? toolSelect.value : '';
|
||
try {
|
||
let url = `/api/sessions/${currentSessionId}/steps/${stepNum}/replay_traces`;
|
||
const queryParams = new URLSearchParams();
|
||
if (toolName) {
|
||
queryParams.append("tool_name", toolName);
|
||
}
|
||
queryParams.append("_t", Date.now());
|
||
url += "?" + queryParams.toString();
|
||
const res = await fetch(url);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data && data.live && data.live.length > 0) {
|
||
renderLiveReplayTreeForStep(stepNum, data.live);
|
||
if (panel) panel.style.borderColor = '#10b981';
|
||
const statusText = document.getElementById(`replay-status-${stepNum}`);
|
||
if (statusText) {
|
||
statusText.innerText = "Loaded cached replay traces.";
|
||
statusText.style.color = "#16a34a";
|
||
}
|
||
const replayIdDisplay = document.getElementById(`replay-id-display-${stepNum}`);
|
||
if (replayIdDisplay && data.replay_id) {
|
||
replayIdDisplay.innerText = `Replay ID: ${data.replay_id}`;
|
||
replayIdDisplay.style.display = 'inline-block';
|
||
} else if (replayIdDisplay) {
|
||
replayIdDisplay.style.display = 'none';
|
||
}
|
||
} else {
|
||
// Clear previous live tree if no cached trace exists for this tool
|
||
if (container) {
|
||
container.innerHTML = '<div style="color: #94a3b8; font-style: italic; text-align: center; padding: 30px 0;" id="replay-live-tree-placeholder-' + stepNum + '">Run a live replay to populate replayed traces tree.</div>';
|
||
}
|
||
if (panel) panel.style.borderColor = '#e2e8f0';
|
||
const statusText = document.getElementById(`replay-status-${stepNum}`);
|
||
if (statusText) {
|
||
statusText.innerText = "Ready";
|
||
statusText.style.color = "#64748b";
|
||
}
|
||
const replayIdDisplay = document.getElementById(`replay-id-display-${stepNum}`);
|
||
if (replayIdDisplay) {
|
||
replayIdDisplay.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`Failed to load cached replay traces for step ${stepNum}:`, err);
|
||
}
|
||
}
|
||
|
||
function renderOriginalReplayTreeForStep(stepNum, treeData) {
|
||
const container = document.getElementById(`replay-original-tree-${stepNum}`);
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
const showLogs = true;
|
||
|
||
if (!treeData || treeData.length === 0) {
|
||
container.innerHTML = '<div style="color: #718096; font-style: italic; text-align: center; padding: 20px 0;">No original traces recorded for this step.</div>';
|
||
return;
|
||
}
|
||
|
||
renderReplayTreeNodes(treeData, container, showLogs, stepNum);
|
||
}
|
||
|
||
function renderLiveReplayTreeForStep(stepNum, treeData) {
|
||
const container = document.getElementById(`replay-live-tree-${stepNum}`);
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
const showLogs = true;
|
||
|
||
if (!treeData || treeData.length === 0) {
|
||
container.innerHTML = '<div style="color: #718096; font-style: italic; text-align: center; padding: 20px 0;">No replayed traces recorded yet.</div>';
|
||
return;
|
||
}
|
||
|
||
renderReplayTreeNodes(treeData, container, showLogs, stepNum);
|
||
}
|
||
|
||
function renderReplayTreeNodes(nodes, container, showLogs, stepNum, depth = 0) {
|
||
const allowedTypes = ['agent', 'tool', 'llm_call', 'node', 'action', 'thinking', 'raw_thinking'];
|
||
const visibleNodes = nodes.filter(n => showLogs || allowedTypes.includes(n.type));
|
||
|
||
visibleNodes.forEach(node => {
|
||
const nodeRow = document.createElement('div');
|
||
nodeRow.style.marginLeft = `${depth * 15}px`;
|
||
nodeRow.style.marginBottom = '6px';
|
||
|
||
if (node.type === 'raw_thinking' || node.type === 'thinking') {
|
||
const thoughtText = extractThinkingText(node.payload);
|
||
const isNative = node.type === 'thinking';
|
||
const boxColor = isNative ? '#16a34a' : '#475569';
|
||
const boxBg = isNative ? '#f0fdf4' : '#f8fafc';
|
||
const boxBorder = isNative ? '#dcfce7' : '#e2e8f0';
|
||
const label = isNative ? '🧠 Model Native Thinking' : '📝 Model Raw Thinking / Plan';
|
||
|
||
nodeRow.innerHTML = `
|
||
<div style="background-color: ${boxBg}; border-left: 4px solid ${boxColor}; border-radius: 6px; padding: 12px 15px; border: 1px solid ${boxBorder}; border-left-width: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<h4 style="margin: 0; color: ${boxColor}; font-size: 0.85rem; font-weight: 700; text-transform: uppercase; display: flex; align-items: center; gap: 6px;">
|
||
<span>${label}</span>
|
||
</h4>
|
||
<div style="display: flex; align-items: center; gap: 8px; font-size: 0.75rem; color: #718096;">
|
||
<span>${formatRelativeTime(node.timestamp)}</span>
|
||
<span class="status-${escapeHtml(node.status)}">${escapeHtml(node.status)}</span>
|
||
</div>
|
||
</div>
|
||
<div style="white-space: pre-wrap; font-size: 0.88rem; color: #1e293b; line-height: 1.6; font-family: inherit;">${escapeHtml(thoughtText || '(No thinking content)')}</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(nodeRow);
|
||
return;
|
||
}
|
||
|
||
nodeRow.style.padding = '8px 12px';
|
||
nodeRow.style.backgroundColor = 'white';
|
||
nodeRow.style.border = '1px solid #e2e8f0';
|
||
nodeRow.style.borderRadius = '6px';
|
||
nodeRow.style.boxShadow = '0 1px 2px rgba(0,0,0,0.01)';
|
||
|
||
const typePill = `<span class="pill type-${escapeHtml(node.type)}" style="font-size: 0.7rem; padding: 2px 6px; margin-right: 8px; border-radius: 4px; font-weight: bold; display: inline-block;">${escapeHtml(node.type)}</span>`;
|
||
const statusColor = node.status === 'success' || node.status === 'SUCCESS' ? '#16a34a' : (node.status === 'failed' || node.status === 'FAILED' ? '#dc2626' : '#d97706');
|
||
const statusDot = `<span style="color: ${statusColor}; font-weight: bold; margin-right: 8px; font-size: 0.78rem;">● ${escapeHtml(node.status)}</span>`;
|
||
|
||
const header = document.createElement('div');
|
||
header.style.display = 'flex';
|
||
header.style.justifyContent = 'space-between';
|
||
header.style.alignItems = 'center';
|
||
header.style.cursor = 'pointer';
|
||
header.style.userSelect = 'none';
|
||
|
||
header.innerHTML = `
|
||
<div style="display: flex; align-items: center;">
|
||
${typePill}
|
||
<span style="font-weight: bold; font-size: 0.82rem; color: #1e293b;">${escapeHtml(node.name)}</span>
|
||
</div>
|
||
<div style="font-size: 0.78rem; display: flex; align-items: center; gap: 8px;">
|
||
<span style="color: #64748b; font-size: 0.75rem;">${formatRelativeTime(node.timestamp)}</span>
|
||
${statusDot}
|
||
${node.duration ? `<span style="color: #64748b;">${formatNodeDurationBreakdown(node)}</span>` : ''}
|
||
</div>
|
||
`;
|
||
|
||
const detailsContainer = document.createElement('div');
|
||
detailsContainer.style.display = node.trace_id === activeReplayNodeId ? 'block' : 'none';
|
||
detailsContainer.style.marginTop = '10px';
|
||
detailsContainer.style.borderTop = '1px solid #f1f5f9';
|
||
detailsContainer.style.paddingTop = '10px';
|
||
|
||
header.onclick = async () => {
|
||
if (detailsContainer.style.display === 'none') {
|
||
activeReplayNodeId = node.trace_id;
|
||
|
||
detailsContainer.innerHTML = '<div style="font-size: 0.8rem; color: #64748b; padding: 10px 0;"><div class="spinner" style="width: 12px; height: 12px; border: 2px solid #64748b; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; display: inline-block; margin-right: 6px; vertical-align: middle;"></div>Loading details...</div>';
|
||
detailsContainer.style.display = 'block';
|
||
|
||
try {
|
||
let traceDetails = node;
|
||
const needsFetch = !node.llm_call && (!node.payload || Object.keys(node.payload).length === 0 ||
|
||
(node.type === 'llm_call' && !node.payload.prompt) ||
|
||
(node.type === 'tool' && node.payload.result === undefined));
|
||
if (needsFetch) {
|
||
let url = `/api/traces/${node.trace_id}`;
|
||
if (currentSessionId && stepNum !== undefined) {
|
||
url += `?session_id=${currentSessionId}&step_number=${stepNum}`;
|
||
}
|
||
const res = await fetch(url);
|
||
if (res.ok) {
|
||
traceDetails = await res.json();
|
||
}
|
||
}
|
||
|
||
if (traceDetails.type === 'thinking' && traceDetails.payload) {
|
||
detailsContainer.innerHTML = `
|
||
<div class="thinking-box native" style="background-color: #f0fdf4; border-left: 4px solid #16a34a; border-radius: 6px; padding: 12px 15px; border: 1px solid #dcfce7; border-left-width: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.02); margin-bottom: 0;">
|
||
<h4 style="margin: 0 0 6px 0; color: #16a34a; font-size: 0.85rem; font-weight: 700; text-transform: uppercase;">🧠 Operator Native Thinking</h4>
|
||
<div style="white-space: pre-wrap; font-size: 0.88rem; color: #1f2937; line-height: 1.6; font-family: inherit;">${escapeHtml(traceDetails.payload.thought)}</div>
|
||
</div>
|
||
`;
|
||
} else if (traceDetails.type === 'raw_thinking' && traceDetails.payload) {
|
||
detailsContainer.innerHTML = `
|
||
<div class="thinking-box raw" style="background-color: #f8fafc; border-left: 4px solid #475569; border-radius: 6px; padding: 12px 15px; border: 1px solid #e2e8f0; border-left-width: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.02); margin-bottom: 0;">
|
||
<h4 style="margin: 0 0 6px 0; color: #475569; font-size: 0.85rem; font-weight: 700; text-transform: uppercase;">📝 Operator Raw Thinking / Plan</h4>
|
||
<div style="white-space: pre-wrap; font-size: 0.88rem; color: #374151; line-height: 1.6; font-family: inherit;">${escapeHtml(traceDetails.payload.thought)}</div>
|
||
</div>
|
||
`;
|
||
} else if (traceDetails.type === 'llm_call') {
|
||
const payloadToRender = traceDetails.llm_call || traceDetails.payload;
|
||
detailsContainer.innerHTML = renderLLMCallPayload(payloadToRender);
|
||
} else if (traceDetails.llm_call) {
|
||
detailsContainer.innerHTML = renderLLMCallPayload(traceDetails.llm_call);
|
||
} else if (traceDetails.type === 'tool') {
|
||
detailsContainer.innerHTML = renderToolPayload(traceDetails.payload);
|
||
} else {
|
||
detailsContainer.innerHTML = `
|
||
<pre style="margin: 0; font-size: 0.8rem; color: #334155; font-family: monospace; white-space: pre-wrap; background-color: #f8fafc; padding: 10px; border-radius: 4px; border: 1px solid #e2e8f0; max-height: 250px; overflow-y: auto;">${escapeHtml(JSON.stringify(traceDetails.payload, null, 2))}</pre>
|
||
`;
|
||
const mediaHtml = detectAndRenderMedia(traceDetails.payload);
|
||
if (mediaHtml) {
|
||
const mediaDiv = document.createElement('div');
|
||
mediaDiv.innerHTML = mediaHtml;
|
||
detailsContainer.appendChild(mediaDiv);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
detailsContainer.innerHTML = `<div style="font-size: 0.8rem; color: #dc2626;">Error loading details: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
} else {
|
||
detailsContainer.style.display = 'none';
|
||
if (activeReplayNodeId === node.trace_id) {
|
||
activeReplayNodeId = null;
|
||
}
|
||
}
|
||
};
|
||
|
||
nodeRow.appendChild(header);
|
||
nodeRow.appendChild(detailsContainer);
|
||
container.appendChild(nodeRow);
|
||
|
||
if (node.children && node.children.length > 0) {
|
||
const childrenWrapper = document.createElement('div');
|
||
renderReplayTreeNodes(node.children, childrenWrapper, showLogs, stepNum, depth + 1);
|
||
container.appendChild(childrenWrapper);
|
||
}
|
||
});
|
||
}
|
||
|
||
function generateReplayId() {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return crypto.randomUUID();
|
||
}
|
||
return 'r-' + Math.random().toString(36).substring(2, 15) + '-' + Date.now().toString(36);
|
||
}
|
||
|
||
async function runReplay(stepNum) {
|
||
const deviceId = "replay-device";
|
||
|
||
const userSubmits = {};
|
||
const inputsToDisable = [];
|
||
|
||
const suffix = `-${stepNum}`;
|
||
const inputs = document.querySelectorAll(`[id^="replay-input-"]`);
|
||
inputs.forEach(inputEl => {
|
||
if (inputEl.id.endsWith(suffix)) {
|
||
const paramName = inputEl.id.substring("replay-input-".length, inputEl.id.length - suffix.length);
|
||
let val = inputEl.value;
|
||
if (inputEl.type === 'checkbox') {
|
||
val = inputEl.checked;
|
||
}
|
||
userSubmits[paramName] = val;
|
||
inputsToDisable.push(inputEl);
|
||
}
|
||
});
|
||
|
||
const statusText = document.getElementById(`replay-status-${stepNum}`);
|
||
const runBtn = document.getElementById(`btn-execute-replay-${stepNum}`);
|
||
const liveTreePanel = document.getElementById(`replay-live-tree-panel-${stepNum}`);
|
||
const liveContainer = document.getElementById(`replay-live-tree-${stepNum}`);
|
||
|
||
// Set loading state
|
||
replayInProgressSteps.add(stepNum);
|
||
runBtn.disabled = true;
|
||
inputsToDisable.forEach(input => { input.disabled = true; });
|
||
// deviceSelect removed
|
||
statusText.innerText = "Replay running in sandbox on device...";
|
||
statusText.style.color = "#d97706";
|
||
liveTreePanel.style.borderColor = "#f59e0b";
|
||
|
||
liveContainer.innerHTML = '<div style="color: #d97706; text-align: center; font-style: italic; padding: 50px 0;"><div class="spinner" style="width: 20px; height: 20px; border: 2px solid #d97706; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; display: inline-block; margin-right: 8px; vertical-align: middle;"></div>Replay executing. Traces stream will populate upon completion...</div>';
|
||
|
||
const replayId = generateReplayId();
|
||
const replayIdDisplay = document.getElementById(`replay-id-display-${stepNum}`);
|
||
if (replayIdDisplay) {
|
||
replayIdDisplay.innerText = `Replay ID: ${replayId}`;
|
||
replayIdDisplay.style.display = 'inline-block';
|
||
}
|
||
|
||
try {
|
||
const toolSelect = document.getElementById(`replay-tool-select-${stepNum}`);
|
||
const toolName = toolSelect ? toolSelect.value : '';
|
||
|
||
const payload = {
|
||
device_id: deviceId,
|
||
user_submits: userSubmits,
|
||
replay_id: replayId
|
||
};
|
||
if (toolName) {
|
||
payload.tool_name = toolName;
|
||
}
|
||
|
||
const res = await fetch(`/api/sessions/${currentSessionId}/steps/${stepNum}/replay`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errText = await res.text();
|
||
let errMsg = errText;
|
||
try {
|
||
const parsed = JSON.parse(errText);
|
||
if (parsed && parsed.detail) errMsg = parsed.detail;
|
||
} catch (_) {}
|
||
throw new Error(errMsg);
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
// Success State
|
||
statusText.innerText = "Replay complete! Traces tree updated.";
|
||
statusText.style.color = "#16a34a";
|
||
liveTreePanel.style.borderColor = "#10b981";
|
||
|
||
renderLiveReplayTreeForStep(stepNum, data.live || []);
|
||
loadReplaySteps(true); // Refresh to update Replayed badges, preserving scroll position
|
||
} catch (err) {
|
||
// Failure State
|
||
statusText.innerText = `Replay failed: ${err.message}`;
|
||
statusText.style.color = "#dc2626";
|
||
liveTreePanel.style.borderColor = "#ef4444";
|
||
liveContainer.innerHTML = `<div style="color: #dc2626; text-align: center; padding: 30px 10px; font-weight: bold;">Replay Execution Error:<br/><pre style="background-color: #fef2f2; color: #991b1b; border: 1px solid #fee2e2; margin-top: 15px; text-align: left; max-height: 200px; overflow-y: auto;">${escapeHtml(err.message)}</pre></div>`;
|
||
} finally {
|
||
// Restore controls
|
||
replayInProgressSteps.delete(stepNum);
|
||
runBtn.disabled = false;
|
||
inputsToDisable.forEach(input => { input.disabled = false; });
|
||
// deviceSelect removed
|
||
}
|
||
}
|
||
|
||
let reloadTimeout = null;
|
||
function scheduleDebouncedReload() {
|
||
if (reloadTimeout) return;
|
||
reloadTimeout = setTimeout(() => {
|
||
reloadTimeout = null;
|
||
if (activeTab === 'steps') {
|
||
loadSteps(true);
|
||
} else if (activeTab === 'traces') {
|
||
loadTree();
|
||
} else if (activeTab === 'replay') {
|
||
// Periodic refresh only targets steps with a replay currently in progress
|
||
if (replaySteps && replaySteps.length > 0 && replayInProgressSteps.size > 0) {
|
||
replaySteps.forEach(s => {
|
||
if (replayInProgressSteps.has(s.step_number)) {
|
||
loadCachedReplayForStep(s.step_number);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}, 400); // Smooth 400ms presentation buffer interlock
|
||
}
|
||
|
||
// --- Steps Timeline & Enhanced Payload Rendering Helpers ---
|
||
|
||
function escapeHtml(text) {
|
||
if (text === null || text === undefined) return '';
|
||
return String(text)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function isHumanThinking(text) {
|
||
if (!text) return false;
|
||
let trimmed = String(text).trim();
|
||
if (!trimmed) return false;
|
||
if (trimmed.startsWith('```')) {
|
||
const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||
if (match) trimmed = match[1].trim();
|
||
}
|
||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return false;
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
if (typeof parsed === 'object' && parsed !== null) return false;
|
||
} catch (e) {}
|
||
if (/^\s*\{.*"label"\s*:.*"results"\s*:/s.test(trimmed) ||
|
||
/^\s*\{.*"point"\s*:.*"label"\s*:/s.test(trimmed) ||
|
||
/^\s*\{.*"action"\s*:/s.test(trimmed) ||
|
||
/^\s*\{.*"name"\s*:/s.test(trimmed) ||
|
||
/^\s*\{\s*"[^"]+"\s*:\s*/.test(trimmed) ||
|
||
/^\s*\[\s*\{\s*"[^"]+"\s*:\s*/.test(trimmed)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function formatRelativeTime(timestamp) {
|
||
if (!sessionStartTime) return new Date(timestamp * 1000).toLocaleTimeString();
|
||
const diff = timestamp - sessionStartTime;
|
||
return `+${diff.toFixed(1)}s`;
|
||
}
|
||
|
||
// Memoized per node object: fresh tree fetches produce fresh objects, so stale
|
||
// entries are dropped automatically by the WeakMap without explicit clearing.
|
||
const descendantDurationCache = new WeakMap();
|
||
|
||
function getDescendantDurations(node) {
|
||
const cached = descendantDurationCache.get(node);
|
||
if (cached) return cached;
|
||
let stats = {
|
||
llm: 0,
|
||
tool: 0,
|
||
action: 0,
|
||
agent: 0
|
||
};
|
||
if (node.children) {
|
||
node.children.forEach(child => {
|
||
if (child.duration) {
|
||
if (child.type === 'llm_call') {
|
||
stats.llm += child.duration;
|
||
} else if (child.type === 'tool') {
|
||
stats.tool += child.duration;
|
||
} else if (child.type === 'action') {
|
||
stats.action += child.duration;
|
||
} else if (child.type === 'agent') {
|
||
stats.agent += child.duration;
|
||
}
|
||
}
|
||
const childStats = getDescendantDurations(child);
|
||
stats.llm += childStats.llm;
|
||
stats.tool += childStats.tool;
|
||
stats.action += childStats.action;
|
||
stats.agent += childStats.agent;
|
||
});
|
||
}
|
||
descendantDurationCache.set(node, stats);
|
||
return stats;
|
||
}
|
||
|
||
function formatNodeDurationBreakdown(node) {
|
||
if (!node.duration) return '';
|
||
const stats = getDescendantDurations(node);
|
||
let breakdown = [];
|
||
|
||
function formatVal(val) {
|
||
if (val > 0 && val < 0.01) return '<0.01s';
|
||
return `${val.toFixed(2)}s`;
|
||
}
|
||
|
||
if (stats.llm > 0) breakdown.push(`LLM: ${formatVal(stats.llm)}`);
|
||
if (stats.tool > 0) breakdown.push(`Tool: ${formatVal(stats.tool)}`);
|
||
if (stats.action > 0) breakdown.push(`Action: ${formatVal(stats.action)}`);
|
||
if (stats.agent > 0) breakdown.push(`Agent: ${formatVal(stats.agent)}`);
|
||
|
||
if (breakdown.length > 0) {
|
||
return `(${formatVal(node.duration)} | ${breakdown.join(', ')})`;
|
||
}
|
||
return `(${formatVal(node.duration)})`;
|
||
}
|
||
|
||
function zoomImage(src) {
|
||
const modal = document.getElementById('zoom-modal');
|
||
const img = document.getElementById('zoom-modal-img');
|
||
img.src = getSafeLocalUrl(src);
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function closeZoomModal() {
|
||
document.getElementById('zoom-modal').style.display = 'none';
|
||
}
|
||
|
||
// Delegated zoom click handler: avoids inlining large data URIs into onclick attributes.
|
||
// Elements marked with data-zoom-img zoom the contained (or own) img's current src.
|
||
document.addEventListener('click', function(e) {
|
||
const zoomTarget = e.target && e.target.closest ? e.target.closest('[data-zoom-img]') : null;
|
||
if (!zoomTarget) return;
|
||
const img = zoomTarget.tagName === 'IMG' ? zoomTarget : zoomTarget.querySelector('img');
|
||
if (img && img.src) {
|
||
zoomImage(img.src);
|
||
}
|
||
});
|
||
|
||
// Lazily-rendered Raw JSON payload blocks: stringify + fill only on first expand.
|
||
const rawJsonPayloadStore = new Map();
|
||
let rawJsonPayloadSeq = 0;
|
||
|
||
function registerRawJsonPayload(payload) {
|
||
rawJsonPayloadSeq += 1;
|
||
rawJsonPayloadStore.set(String(rawJsonPayloadSeq), payload);
|
||
// Bound memory: drop entries whose <details> block left the DOM
|
||
// (skip the most recent few, which may not be attached yet)
|
||
if (rawJsonPayloadStore.size > 50) {
|
||
rawJsonPayloadStore.forEach((_, key) => {
|
||
if (Number(key) <= rawJsonPayloadSeq - 10 &&
|
||
!document.querySelector(`details[data-raw-json-id="${key}"]`)) {
|
||
rawJsonPayloadStore.delete(key);
|
||
}
|
||
});
|
||
}
|
||
return rawJsonPayloadSeq;
|
||
}
|
||
|
||
function onRawJsonToggle(detailsEl) {
|
||
if (!detailsEl || !detailsEl.open) return;
|
||
if (detailsEl.getAttribute('data-raw-loaded') === 'true') return;
|
||
detailsEl.setAttribute('data-raw-loaded', 'true');
|
||
const id = detailsEl.getAttribute('data-raw-json-id');
|
||
const pre = detailsEl.querySelector('pre');
|
||
if (pre && rawJsonPayloadStore.has(id)) {
|
||
pre.textContent = JSON.stringify(rawJsonPayloadStore.get(id), null, 2);
|
||
rawJsonPayloadStore.delete(id);
|
||
}
|
||
}
|
||
|
||
function extractThinkingText(payload) {
|
||
if (!payload) return '';
|
||
if (typeof payload === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(payload);
|
||
return parsed.thought || parsed.text || parsed.content || payload;
|
||
} catch (e) {
|
||
return payload;
|
||
}
|
||
}
|
||
if (typeof payload === 'object') {
|
||
return payload.thought || payload.text || payload.content || JSON.stringify(payload, null, 2);
|
||
}
|
||
return String(payload);
|
||
}
|
||
|
||
function detectAndRenderNativeThoughts(payload) {
|
||
let thoughts = [];
|
||
if (payload && payload.response && Array.isArray(payload.response)) {
|
||
payload.response.forEach(gen => {
|
||
if (gen && typeof gen === 'object') {
|
||
const content = gen.content;
|
||
if (Array.isArray(content)) {
|
||
content.forEach(block => {
|
||
if (block && block.type === 'thinking' && block.thinking) {
|
||
thoughts.push(block.thinking.trim ? block.thinking.trim() : block.thinking);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
if (thoughts.length === 0) return '';
|
||
const thinkingText = thoughts.join('\n\n');
|
||
return `
|
||
<div class="thought-container" style="background-color: #f0fdf4; border-left: 4px solid #16a34a; padding: 16px; margin-bottom: 15px; border-radius: 8px; border: 1px solid #dcfce7; border-left-width: 4px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);">
|
||
<div style="font-weight: 700; color: #16a34a; margin-bottom: 8px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; display: flex; align-items: center; gap: 6px;">
|
||
<span>🧠 Model Native Thinking</span>
|
||
</div>
|
||
<div style="white-space: pre-wrap; font-family: inherit; color: #1e293b; font-size: 0.9rem; line-height: 1.6;">${escapeHtml(thinkingText)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderLLMCallPayload(payload) {
|
||
let html = '';
|
||
|
||
// 1. Native thinking block at the very top
|
||
const thinkingHtml = detectAndRenderNativeThoughts(payload);
|
||
if (thinkingHtml) {
|
||
html += thinkingHtml;
|
||
}
|
||
|
||
// 2. Error message if present
|
||
if (payload.error) {
|
||
html += `
|
||
<div style="background-color: #fef2f2; border: 1px solid #fee2e2; border-left: 4px solid #ef4444; border-radius: 6px; padding: 15px; margin-bottom: 15px;">
|
||
<h4 style="margin: 0 0 8px 0; color: #ef4444; font-weight: bold; font-size: 0.85rem; text-transform: uppercase;">❌ LLM Error</h4>
|
||
<pre class="light-code" style="margin: 0; font-size: 0.85rem; color: #991b1b; font-family: monospace; white-space: pre-wrap;">${escapeHtml(payload.error)}</pre>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 3. Conversation thread messages
|
||
if (payload.messages && Array.isArray(payload.messages)) {
|
||
html += '<div class="llm-conversation" style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 20px;">';
|
||
|
||
payload.messages.forEach(msg => {
|
||
const isSystem = msg.type === 'system';
|
||
const roleColor = isSystem ? '#475569' : (msg.type === 'human' ? '#2563eb' : '#059669');
|
||
const roleBg = isSystem ? '#f8fafc' : (msg.type === 'human' ? '#eff6ff' : '#ecfdf5');
|
||
const roleLabel = isSystem ? 'System Prompt' : (msg.type === 'human' ? 'User / Prompt' : 'AI / Assistant');
|
||
|
||
if (isSystem) {
|
||
html += `
|
||
<details style="border: 1px solid #e2e8f0; border-radius: 6px; background-color: #f8fafc; overflow: hidden;">
|
||
<summary style="padding: 10px 15px; font-weight: bold; font-size: 0.85rem; color: #475569; cursor: pointer; background-color: #f1f5f9; outline: none; user-select: none;">
|
||
⚙️ ${roleLabel} (Collapsible, click to expand)
|
||
</summary>
|
||
<div style="padding: 15px; border-top: 1px solid #e2e8f0; white-space: pre-wrap; font-family: monospace; font-size: 0.82rem; color: #334155; max-height: 250px; overflow-y: auto; line-height: 1.5;">${escapeHtml(msg.content)}</div>
|
||
</details>
|
||
`;
|
||
} else {
|
||
let contentHtml = '';
|
||
if (typeof msg.content === 'string') {
|
||
contentHtml = `<div style="white-space: pre-wrap; font-family: inherit; font-size: 0.88rem; line-height: 1.5; color: #1e293b;">${escapeHtml(msg.content)}</div>`;
|
||
} else if (Array.isArray(msg.content)) {
|
||
contentHtml = '<div style="display: flex; flex-direction: column; gap: 10px;">';
|
||
msg.content.forEach(block => {
|
||
if (block.type === 'text' && block.text) {
|
||
contentHtml += `<div style="white-space: pre-wrap; font-family: inherit; font-size: 0.88rem; line-height: 1.5; color: #1e293b;">${escapeHtml(block.text)}</div>`;
|
||
} else if (block.type === 'image_url' && block.image_url && block.image_url.url) {
|
||
contentHtml += `
|
||
<div class="media-container" style="max-width: 280px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; background-color: #f8fafc; cursor: zoom-in;" data-zoom-img>
|
||
<img src="${escapeHtml(block.image_url.url)}" style="width: 100%; display: block;"/>
|
||
</div>
|
||
`;
|
||
} else {
|
||
contentHtml += `<pre style="margin: 0; font-size: 0.8rem; background-color: #f1f5f9; padding: 5px; border-radius: 4px; color: #334155;">${escapeHtml(JSON.stringify(block, null, 2))}</pre>`;
|
||
}
|
||
});
|
||
contentHtml += '</div>';
|
||
}
|
||
|
||
html += `
|
||
<div class="msg-bubble" style="background-color: ${roleBg}; border: 1px solid ${roleColor}20; border-radius: 8px; overflow: hidden; border-left: 4px solid ${roleColor}; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<div style="padding: 6px 12px; background-color: ${roleColor}08; border-bottom: 1px solid ${roleColor}0f; color: ${roleColor}; font-weight: bold; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;">
|
||
${roleLabel}
|
||
</div>
|
||
<div style="padding: 12px 15px;">
|
||
${contentHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
html += '</div>';
|
||
}
|
||
|
||
// 4. Response output if not already fully merged
|
||
if (payload.response && Array.isArray(payload.response)) {
|
||
html += '<div style="margin-top: 15px; display: flex; flex-direction: column; gap: 12px;">';
|
||
payload.response.forEach((gen, idx) => {
|
||
let textContent = '';
|
||
let structuredBlocks = [];
|
||
|
||
if (typeof gen === 'string') {
|
||
textContent = gen;
|
||
} else if (gen && typeof gen === 'object') {
|
||
if (typeof gen.content === 'string') {
|
||
textContent = gen.content;
|
||
} else if (Array.isArray(gen.content)) {
|
||
structuredBlocks = gen.content;
|
||
} else if (gen.text) {
|
||
textContent = gen.text;
|
||
}
|
||
}
|
||
|
||
let responseHtml = '';
|
||
if (structuredBlocks.length > 0) {
|
||
responseHtml = '<div style="display: flex; flex-direction: column; gap: 10px;">';
|
||
structuredBlocks.forEach(block => {
|
||
if (block.type === 'text' && block.text) {
|
||
responseHtml += `<div style="white-space: pre-wrap; font-family: inherit; font-size: 0.88rem; line-height: 1.5; color: #1e293b;">${escapeHtml(block.text)}</div>`;
|
||
} else if (block.type === 'thinking') {
|
||
responseHtml += `
|
||
<div style="font-size: 0.8rem; color: #16a34a; font-style: italic; font-weight: bold; border-left: 2px solid #16a34a; padding-left: 6px; margin: 4px 0;">
|
||
↳ [Native thinking block processed and displayed above]
|
||
</div>
|
||
`;
|
||
} else {
|
||
responseHtml += `<pre style="margin: 0; font-size: 0.8rem; background-color: #f1f5f9; padding: 5px; border-radius: 4px; color: #334155;">${escapeHtml(JSON.stringify(block, null, 2))}</pre>`;
|
||
}
|
||
});
|
||
responseHtml += '</div>';
|
||
} else if (textContent) {
|
||
responseHtml = `<div style="white-space: pre-wrap; font-family: inherit; font-size: 0.88rem; line-height: 1.5; color: #1e293b;">${escapeHtml(textContent)}</div>`;
|
||
}
|
||
|
||
if (responseHtml) {
|
||
html += `
|
||
<div class="msg-bubble" style="background-color: #ecfdf5; border: 1px solid #05966920; border-radius: 8px; overflow: hidden; border-left: 4px solid #059669; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<div style="padding: 6px 12px; background-color: #05966908; border-bottom: 1px solid #0596690f; color: #059669; font-weight: bold; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; display: flex; justify-content: space-between; align-items: center;">
|
||
<span>AI Response / Output</span>
|
||
${payload.response.length > 1 ? `<span style="font-size: 0.7rem; background-color: #059669; color: white; padding: 1px 5px; border-radius: 4px;">Gen ${idx+1}</span>` : ''}
|
||
</div>
|
||
<div style="padding: 12px 15px;">
|
||
${responseHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// 5. Add custom media render blocks (screenshots etc.) if any match the parameters
|
||
const mediaHtml = detectAndRenderMedia(payload);
|
||
if (mediaHtml) {
|
||
html += `<div style="margin-top: 15px;">${mediaHtml}</div>`;
|
||
}
|
||
|
||
// 6. Collapsible Raw JSON block at the bottom (lazily filled on first expand)
|
||
html += `
|
||
<details style="margin-top: 25px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;" data-raw-json-id="${registerRawJsonPayload(payload)}" ontoggle="onRawJsonToggle(this)">
|
||
<summary style="padding: 8px 12px; font-weight: bold; font-size: 0.8rem; color: #64748b; cursor: pointer; background-color: #f8fafc; outline: none; user-select: none;">
|
||
⚙️ View Raw JSON Payload
|
||
</summary>
|
||
<div style="padding: 15px; border-top: 1px solid #e2e8f0; background-color: #0f172a; overflow-x: auto;">
|
||
<pre style="margin: 0; font-size: 0.8rem; color: #f8fafc; font-family: monospace; white-space: pre-wrap; max-height: 350px; overflow-y: auto;"></pre>
|
||
</div>
|
||
</details>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
function renderToolPayload(payload) {
|
||
let html = '';
|
||
|
||
if (payload.args) {
|
||
html += `
|
||
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px 15px; margin-bottom: 12px; border-left: 4px solid #64748b; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<h4 style="margin: 0 0 6px 0; color: #475569; font-size: 0.8rem; font-weight: bold; text-transform: uppercase; letter-spacing: 0.05em;">🔧 Tool Arguments</h4>
|
||
<pre class="light-code" style="margin: 0; font-size: 0.82rem; color: #334155; font-family: monospace; white-space: pre-wrap; line-height: 1.4;">${escapeHtml(JSON.stringify(payload.args, null, 2))}</pre>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (payload.result !== undefined && payload.result !== null) {
|
||
const resultText = typeof payload.result === 'object' ? JSON.stringify(payload.result, null, 2) : String(payload.result);
|
||
html += `
|
||
<div style="background-color: #f0fdf4; border: 1px solid #dcfce7; border-radius: 6px; padding: 12px 15px; margin-bottom: 12px; border-left: 4px solid #16a34a; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<h4 style="margin: 0 0 6px 0; color: #16a34a; font-size: 0.8rem; font-weight: bold; text-transform: uppercase; letter-spacing: 0.05em;">🟢 Tool Result</h4>
|
||
<pre class="light-code" style="margin: 0; font-size: 0.82rem; color: #14532d; font-family: monospace; white-space: pre-wrap; line-height: 1.4;">${escapeHtml(resultText)}</pre>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (payload.error) {
|
||
html += `
|
||
<div style="background-color: #fef2f2; border: 1px solid #fee2e2; border-radius: 6px; padding: 12px 15px; margin-bottom: 12px; border-left: 4px solid #ef4444; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<h4 style="margin: 0 0 6px 0; color: #ef4444; font-size: 0.8rem; font-weight: bold; text-transform: uppercase; letter-spacing: 0.05em;">🔴 Tool Error</h4>
|
||
<pre class="light-code" style="margin: 0; font-size: 0.82rem; color: #991b1b; font-family: monospace; white-space: pre-wrap; line-height: 1.4;">${escapeHtml(payload.error)}</pre>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Add custom media render blocks (screenshots etc.) if any match the parameters
|
||
const mediaHtml = detectAndRenderMedia(payload);
|
||
if (mediaHtml) {
|
||
html += `<div style="margin-top: 15px;">${mediaHtml}</div>`;
|
||
}
|
||
|
||
// Collapsible Raw JSON block at the bottom (lazily filled on first expand)
|
||
html += `
|
||
<details style="margin-top: 15px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;" data-raw-json-id="${registerRawJsonPayload(payload)}" ontoggle="onRawJsonToggle(this)">
|
||
<summary style="padding: 6px 10px; font-weight: bold; font-size: 0.75rem; color: #64748b; cursor: pointer; background-color: #f8fafc; outline: none; user-select: none;">
|
||
⚙️ View Raw JSON Payload
|
||
</summary>
|
||
<div style="padding: 10px; border-top: 1px solid #e2e8f0; background-color: #0f172a; overflow-x: auto;">
|
||
<pre style="margin: 0; font-size: 0.8rem; color: #f8fafc; font-family: monospace; white-space: pre-wrap; max-height: 250px; overflow-y: auto;"></pre>
|
||
</div>
|
||
</details>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
async function loadSteps(silent = false) {
|
||
if (!currentSessionId) return;
|
||
const container = document.getElementById('steps-container');
|
||
if (!container) return;
|
||
|
||
const existingCards = container.querySelectorAll('.step-card');
|
||
|
||
if (!silent) {
|
||
container.innerHTML = '<div style="padding: 30px; color: #718096; text-align: center; font-size: 1rem;"><div class="spinner" style="width: 20px; height: 20px; border: 2px solid #3182ce; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; display: inline-block; margin-right: 8px; vertical-align: middle;"></div>Loading steps timeline...</div>';
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/sessions/${currentSessionId}/steps`);
|
||
if (!res.ok) throw new Error(await res.text());
|
||
const steps = await res.json();
|
||
|
||
if (steps.length === 0) {
|
||
container.innerHTML = '<div style="padding: 40px; color: #718096; text-align: center; font-size: 1.1rem;">👣 No steps recorded yet for this session.</div>';
|
||
return;
|
||
}
|
||
|
||
// Silent update optimization: if card count matches step count, return immediately to preserve DOM & expand state
|
||
if (silent && existingCards.length === steps.length) {
|
||
return;
|
||
}
|
||
|
||
// Append only new steps when updating silently
|
||
let stepsToRender = steps;
|
||
if (silent && existingCards.length > 0 && steps.length > existingCards.length) {
|
||
stepsToRender = steps.slice(existingCards.length);
|
||
} else {
|
||
container.innerHTML = '';
|
||
}
|
||
|
||
stepsToRender.forEach(step => {
|
||
const hasPreImage = step.pre_image_name && step.pre_image_name !== 'None' && step.pre_image_name !== 'null' && String(step.pre_image_name).trim() !== '';
|
||
let hasPostImage = step.post_image_name && step.post_image_name !== 'None' && step.post_image_name !== 'null' && String(step.post_image_name).trim() !== '';
|
||
// If pre and post are the exact same image, only show one (pre)
|
||
if (hasPreImage && hasPostImage && step.pre_image_name === step.post_image_name) {
|
||
hasPostImage = false;
|
||
}
|
||
const hasImages = hasPreImage || hasPostImage;
|
||
|
||
const stepCard = document.createElement('div');
|
||
stepCard.className = 'step-card';
|
||
stepCard.id = `step-card-${step.step_id}`;
|
||
stepCard.style.cssText = 'background: white; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.03); overflow: hidden;';
|
||
|
||
// Header HTML
|
||
const headerHtml = `
|
||
<div style="background-color: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center;">
|
||
<h3 style="margin: 0; color: #1e293b; font-size: 1.05rem; display: flex; align-items: center; gap: 10px;">
|
||
<span style="background-color: #3182ce; color: white; border-radius: 50%; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: bold;">${step.step_number}</span>
|
||
<span>${escapeHtml(step.summary || 'Step Executing...')}</span>
|
||
</h3>
|
||
<span style="color: #64748b; font-size: 0.85rem; font-weight: 500;">${formatRelativeTime(step.timestamp)}</span>
|
||
</div>
|
||
`;
|
||
|
||
// Content panel grid
|
||
let contentHtml = `<div style="padding: 20px; display: flex; flex-direction: column; gap: 16px;">`;
|
||
contentHtml += `<div style="display: grid; grid-template-columns: ${hasImages ? '1fr 1fr' : '1fr'}; gap: 20px;">`;
|
||
|
||
// Left Column (Thoughts and Actions)
|
||
contentHtml += `<div style="display: flex; flex-direction: column; gap: 12px;">`;
|
||
|
||
if (step.operator_native_thinking) {
|
||
contentHtml += `
|
||
<div class="thinking-box native" style="background-color: #f0fdf4; border-left: 4px solid #16a34a; border-radius: 6px; padding: 12px 15px; border: 1px solid #dcfce7; border-left-width: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<h4 style="margin: 0 0 6px 0; color: #16a34a; font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; display: flex; align-items: center; gap: 6px;">
|
||
<span>🧠 Native Thinking Process</span>
|
||
</h4>
|
||
<div style="white-space: pre-wrap; font-size: 0.88rem; color: #1f2937; line-height: 1.6; font-family: inherit;">${escapeHtml(step.operator_native_thinking)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (step.operator_raw_thinking && isHumanThinking(step.operator_raw_thinking)) {
|
||
contentHtml += `
|
||
<div class="thinking-box raw" style="background-color: #f8fafc; border-left: 4px solid #475569; border-radius: 6px; padding: 12px 15px; border: 1px solid #e2e8f0; border-left-width: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<h4 style="margin: 0 0 6px 0; color: #475569; font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; display: flex; align-items: center; gap: 6px;">
|
||
<span>📝 Raw Thinking / Plan</span>
|
||
</h4>
|
||
<div style="white-space: pre-wrap; font-size: 0.88rem; color: #374151; line-height: 1.6; font-family: inherit;">${escapeHtml(step.operator_raw_thinking)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (step.action_taken) {
|
||
contentHtml += `
|
||
<div style="background-color: #eff6ff; border: 1px solid #dbeafe; border-radius: 6px; padding: 12px 15px; border-left: 4px solid #2563eb;">
|
||
<h4 style="margin: 0 0 6px 0; color: #1d4ed8; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;">⚡ Action Executed</h4>
|
||
<pre class="light-code" style="margin: 0; font-size: 0.82rem; color: #1e3a8a; font-family: monospace; white-space: pre-wrap; line-height: 1.4;">${escapeHtml(JSON.stringify(step.action_taken, null, 2))}</pre>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (step.last_execution_result) {
|
||
const isSuccess = step.last_execution_result.success !== false;
|
||
const resColor = isSuccess ? '#16a34a' : '#dc2626';
|
||
const resBg = isSuccess ? '#f0fdf4' : '#fef2f2';
|
||
const resBorder = isSuccess ? '#dcfce7' : '#fee2e2';
|
||
|
||
contentHtml += `
|
||
<div style="background-color: ${resBg}; border: 1px solid ${resBorder}; border-radius: 6px; padding: 12px 15px; border-left: 4px solid ${resColor};">
|
||
<h4 style="margin: 0 0 6px 0; color: ${resColor}; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;">✓ Execution/Validation Result</h4>
|
||
<pre class="light-code" style="margin: 0; font-size: 0.82rem; color: ${isSuccess ? '#14532d' : '#7f1d1d'}; font-family: monospace; white-space: pre-wrap; line-height: 1.4;">${escapeHtml(JSON.stringify(step.last_execution_result, null, 2))}</pre>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
contentHtml += `</div>`; // End Left Column
|
||
|
||
// Right Column (Screenshots)
|
||
if (hasImages) {
|
||
contentHtml += `<div style="display: flex; flex-direction: column; gap: 15px; align-items: center; justify-content: start;">`;
|
||
contentHtml += `<div style="display: flex; gap: 12px; width: 100%; justify-content: center;">`;
|
||
|
||
if (hasPreImage) {
|
||
const preSrc = (step.pre_image_name.startsWith('data:') || step.pre_image_name.startsWith('http') || step.pre_image_name.startsWith('/'))
|
||
? step.pre_image_name
|
||
: `/images/${step.pre_image_name}`;
|
||
contentHtml += `
|
||
<div style="flex: 1; text-align: center; display: flex; flex-direction: column; gap: 6px; max-width: ${hasPostImage ? '100%' : '360px'}; margin: 0 auto;">
|
||
<span style="font-size: 0.75rem; font-weight: 600; color: #64748b;">${hasPostImage ? 'Pre-Action (Before)' : 'Screenshot'}</span>
|
||
<div class="image-wrapper" style="border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; background-color: #f8fafc; cursor: zoom-in; box-shadow: 0 1px 3px rgba(0,0,0,0.05); display: flex; align-items: center; justify-content: center;" onclick="zoomImage('${escapeHtml(preSrc)}')">
|
||
<div style="position: relative; display: inline-block; max-width: 100%;">
|
||
<img id="step-img-${escapeHtml(step.step_id)}" src="${escapeHtml(preSrc)}" style="max-width: 100%; max-height: 340px; object-fit: contain; display: block; margin: 0 auto; transition: transform 0.2s;"/>
|
||
<div id="step-overlay-${escapeHtml(step.step_id)}" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (hasPostImage) {
|
||
const postSrc = (step.post_image_name.startsWith('data:') || step.post_image_name.startsWith('http') || step.post_image_name.startsWith('/'))
|
||
? step.post_image_name
|
||
: `/images/${step.post_image_name}`;
|
||
contentHtml += `
|
||
<div style="flex: 1; text-align: center; display: flex; flex-direction: column; gap: 6px; max-width: ${hasPreImage ? '100%' : '360px'}; margin: 0 auto;">
|
||
<span style="font-size: 0.75rem; font-weight: 600; color: #64748b;">Post-Action (After)</span>
|
||
<div class="image-wrapper" style="border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; background-color: #f8fafc; cursor: zoom-in; box-shadow: 0 1px 3px rgba(0,0,0,0.05);" onclick="zoomImage('${escapeHtml(postSrc)}')">
|
||
<img src="${escapeHtml(postSrc)}" style="max-width: 100%; max-height: 340px; object-fit: contain; display: block; margin: 0 auto; transition: transform 0.2s;"/>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
contentHtml += `</div>`;
|
||
contentHtml += `</div>`; // End Right Column
|
||
}
|
||
|
||
contentHtml += `</div>`; // End Grid Column
|
||
|
||
// Sub-Traces section (mini-expander)
|
||
const isOpen = userExpandedStepIds.has(step.step_id);
|
||
const isLoaded = stepTracesCache[step.step_id] !== undefined;
|
||
|
||
contentHtml += `
|
||
<div class="step-traces-expander" style="border-top: 1px solid #f1f5f9; padding-top: 15px; margin-top: 5px;">
|
||
<details ${isOpen ? 'open' : ''} ontoggle="onStepDetailsToggle(this, '${escapeHtml(step.step_id)}')">
|
||
<summary style="font-size: 0.82rem; font-weight: 600; color: #475569; cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none; outline: none; padding-right: 5px;">
|
||
<div style="display: flex; align-items: center; gap: 6px;">
|
||
🔍 Click to inspect Step Details & Sub-Traces
|
||
</div>
|
||
<div onclick="event.stopPropagation();" style="display: flex; align-items: center; gap: 6px; font-weight: normal;">
|
||
<label for="trace-filter-${escapeHtml(step.step_id)}" style="font-size: 0.75rem; color: #64748b;">Trace Type:</label>
|
||
<select id="trace-filter-${escapeHtml(step.step_id)}" onchange="filterStepTraces('${escapeHtml(step.step_id)}', this.value)" onclick="event.stopPropagation();" style="font-size: 0.75rem; padding: 2px 8px; border-radius: 4px; border: 1px solid #cbd5e1; background: white; color: #334155; cursor: pointer; outline: none; font-weight: 500;">
|
||
<option value="all">All</option>
|
||
</select>
|
||
</div>
|
||
</summary>
|
||
<div class="step-child-traces-list" id="step-traces-${escapeHtml(step.step_id)}" ${isLoaded ? 'data-loaded="true"' : ''} style="margin-top: 12px; padding: 12px 15px; border-left: 2px dashed #cbd5e1; background-color: #f8fafc; border-radius: 0 6px 6px 0; display: flex; flex-direction: column; gap: 8px;">
|
||
<div style="font-size: 0.8rem; color: #64748b;">Loading step child traces...</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
`;
|
||
|
||
contentHtml += `</div>`; // End Step Content Area
|
||
|
||
stepCard.innerHTML = headerHtml + contentHtml;
|
||
container.appendChild(stepCard);
|
||
|
||
if (hasPreImage && step.action_taken) {
|
||
setupImageCoordinates(`step-img-${step.step_id}`, `step-overlay-${step.step_id}`, step.action_taken);
|
||
}
|
||
|
||
if (isOpen) {
|
||
const targetDiv = document.getElementById(`step-traces-${step.step_id}`);
|
||
const details = targetDiv ? targetDiv.closest('details') : null;
|
||
loadStepTraces(step.step_id, details);
|
||
}
|
||
});
|
||
} catch (err) {
|
||
container.innerHTML = `<div style="padding: 20px; color: #dc2626; text-align: center; font-weight: bold;">Error loading steps timeline: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
let stepTracesCache = {};
|
||
let stepFilterMap = {};
|
||
|
||
function getUniqueTraceTypes(nodes) {
|
||
const types = new Set();
|
||
function collect(nodeList) {
|
||
if (!nodeList) return;
|
||
nodeList.forEach(node => {
|
||
if (node.type) {
|
||
types.add(node.type);
|
||
}
|
||
if (node.children && node.children.length > 0) {
|
||
collect(node.children);
|
||
}
|
||
});
|
||
}
|
||
collect(nodes);
|
||
return Array.from(types).sort();
|
||
}
|
||
|
||
function populateTraceTypeDropdown(stepId, nodes) {
|
||
const select = document.getElementById(`trace-filter-${stepId}`);
|
||
if (!select) return;
|
||
|
||
const uniqueTypes = getUniqueTraceTypes(nodes);
|
||
const currentVal = stepFilterMap[stepId] || 'all';
|
||
|
||
select.innerHTML = '<option value="all">All</option>';
|
||
|
||
uniqueTypes.forEach(type => {
|
||
const opt = document.createElement('option');
|
||
opt.value = type;
|
||
const label = type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||
opt.textContent = label;
|
||
select.appendChild(opt);
|
||
});
|
||
|
||
select.value = uniqueTypes.includes(currentVal) ? currentVal : 'all';
|
||
}
|
||
|
||
function filterStepTraces(stepId, selectedType) {
|
||
stepFilterMap[stepId] = selectedType;
|
||
const targetDiv = document.getElementById(`step-traces-${stepId}`);
|
||
if (!targetDiv || !stepTracesCache[stepId]) return;
|
||
|
||
targetDiv.innerHTML = '';
|
||
renderMiniTree(stepTracesCache[stepId], targetDiv, 0, selectedType);
|
||
}
|
||
|
||
async function loadStepTraces(stepId, detailsElement, force = false) {
|
||
const targetDiv = document.getElementById(`step-traces-${stepId}`);
|
||
if (!targetDiv) return;
|
||
if (!force && targetDiv.getAttribute('data-loaded') === 'true') return;
|
||
|
||
const currentFilter = stepFilterMap[stepId] || 'all';
|
||
|
||
if (!force && stepTracesCache[stepId]) {
|
||
targetDiv.innerHTML = '';
|
||
targetDiv.setAttribute('data-loaded', 'true');
|
||
populateTraceTypeDropdown(stepId, stepTracesCache[stepId]);
|
||
renderMiniTree(stepTracesCache[stepId], targetDiv, 0, currentFilter);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/steps/${stepId}/traces?session_id=${currentSessionId || ''}`);
|
||
if (!res.ok) {
|
||
targetDiv.innerHTML = '<div style="font-size: 0.8rem; color: #64748b;">No direct tool or LLM traces associated with this step.</div>';
|
||
targetDiv.setAttribute('data-loaded', 'true');
|
||
populateTraceTypeDropdown(stepId, []);
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
|
||
stepTracesCache[stepId] = data;
|
||
targetDiv.innerHTML = '';
|
||
targetDiv.setAttribute('data-loaded', 'true');
|
||
|
||
populateTraceTypeDropdown(stepId, data);
|
||
|
||
if (data.length === 0) {
|
||
targetDiv.innerHTML = '<div style="font-size: 0.8rem; color: #64748b;">No direct tool or LLM traces associated with this step.</div>';
|
||
return;
|
||
}
|
||
|
||
renderMiniTree(data, targetDiv, 0, currentFilter);
|
||
|
||
// Ensure streams are rendered for newly attached DOM elements
|
||
for (let traceId in nodeStreams) {
|
||
updateStreamDisplay(traceId);
|
||
}
|
||
} catch (err) {
|
||
targetDiv.innerHTML = `<div style="font-size: 0.8rem; color: #dc2626;">Failed to load traces: ${escapeHtml(err.message)}</div>`;
|
||
populateTraceTypeDropdown(stepId, []);
|
||
}
|
||
}
|
||
|
||
function hasMatchingDescendant(children, filterType) {
|
||
if (filterType === 'all' || !children) return false;
|
||
for (const child of children) {
|
||
if (child.type === filterType || hasMatchingDescendant(child.children, filterType)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function renderMiniTree(nodes, container, depth = 0, filterType = 'all') {
|
||
nodes.forEach(node => {
|
||
const selfMatches = (filterType === 'all' || node.type === filterType);
|
||
const childMatches = hasMatchingDescendant(node.children, filterType);
|
||
|
||
if (!selfMatches && !childMatches) {
|
||
return;
|
||
}
|
||
|
||
const nodeRow = document.createElement('div');
|
||
nodeRow.style.marginLeft = `${depth * 16}px`;
|
||
nodeRow.style.marginBottom = '6px';
|
||
|
||
if (node.type === 'raw_thinking' || node.type === 'thinking') {
|
||
const thoughtText = extractThinkingText(node.payload);
|
||
const isNative = node.type === 'thinking';
|
||
const boxColor = isNative ? '#16a34a' : '#475569';
|
||
const boxBg = isNative ? '#f0fdf4' : '#f8fafc';
|
||
const boxBorder = isNative ? '#dcfce7' : '#e2e8f0';
|
||
const label = isNative ? '🧠 Model Native Thinking' : '📝 Model Raw Thinking / Plan';
|
||
|
||
nodeRow.innerHTML = `
|
||
<div style="background-color: ${boxBg}; border-left: 4px solid ${boxColor}; border-radius: 6px; padding: 12px 15px; border: 1px solid ${boxBorder}; border-left-width: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<h4 style="margin: 0; color: ${boxColor}; font-size: 0.82rem; font-weight: 700; text-transform: uppercase; display: flex; align-items: center; gap: 6px;">
|
||
<span>${label}</span>
|
||
</h4>
|
||
<div style="display: flex; align-items: center; gap: 8px; font-size: 0.75rem; color: #718096;">
|
||
<span>${formatRelativeTime(node.timestamp)}</span>
|
||
<span class="status-${escapeHtml(node.status)}">${escapeHtml(node.status)}</span>
|
||
</div>
|
||
</div>
|
||
<div style="white-space: pre-wrap; font-size: 0.85rem; color: #1e293b; line-height: 1.6; font-family: inherit;">${escapeHtml(thoughtText || '(No thinking content)')}</div>
|
||
</div>
|
||
`;
|
||
if (node.children && node.children.length > 0) {
|
||
const childrenWrapper = document.createElement('div');
|
||
renderMiniTree(node.children, childrenWrapper, depth + 1, filterType);
|
||
nodeRow.appendChild(childrenWrapper);
|
||
}
|
||
container.appendChild(nodeRow);
|
||
return;
|
||
}
|
||
|
||
nodeRow.style.padding = '8px 12px';
|
||
nodeRow.style.backgroundColor = 'white';
|
||
nodeRow.style.border = '1px solid #e2e8f0';
|
||
nodeRow.style.borderRadius = '6px';
|
||
nodeRow.style.boxShadow = '0 1px 2px rgba(0,0,0,0.01)';
|
||
|
||
const typePill = `<span class="pill type-${escapeHtml(node.type)}" style="font-size: 0.7rem; padding: 2px 6px; margin-right: 8px; border-radius: 4px; font-weight: bold; display: inline-block;">${escapeHtml(node.type)}</span>`;
|
||
const statusColor = node.status === 'success' ? '#16a34a' : (node.status === 'failed' ? '#dc2626' : '#d97706');
|
||
const statusDot = `<span style="color: ${statusColor}; font-weight: bold; margin-right: 8px; font-size: 0.78rem;">● ${escapeHtml(node.status)}</span>`;
|
||
|
||
const header = document.createElement('div');
|
||
header.style.display = 'flex';
|
||
header.style.justifyContent = 'space-between';
|
||
header.style.alignItems = 'center';
|
||
header.style.cursor = 'pointer';
|
||
header.style.userSelect = 'none';
|
||
|
||
header.innerHTML = `
|
||
<div style="display: flex; align-items: center;">
|
||
${typePill}
|
||
<span style="font-weight: bold; font-size: 0.82rem; color: #1e293b;">${escapeHtml(node.name)}</span>
|
||
</div>
|
||
<div style="font-size: 0.78rem; display: flex; align-items: center; gap: 8px;">
|
||
<span style="color: #64748b; font-size: 0.75rem;">${formatRelativeTime(node.timestamp)}</span>
|
||
${statusDot}
|
||
${node.duration ? `<span style="color: #64748b;">${formatNodeDurationBreakdown(node)}</span>` : ''}
|
||
</div>
|
||
`;
|
||
|
||
const detailsContainer = document.createElement('div');
|
||
detailsContainer.style.display = 'none';
|
||
detailsContainer.style.marginTop = '10px';
|
||
detailsContainer.style.borderTop = '1px solid #f1f5f9';
|
||
detailsContainer.style.paddingTop = '10px';
|
||
|
||
header.onclick = async () => {
|
||
if (detailsContainer.style.display === 'none') {
|
||
if (!detailsContainer.getAttribute('data-loaded')) {
|
||
detailsContainer.innerHTML = '<div style="font-size: 0.8rem; color: #64748b; padding: 10px 0;"><div class="spinner" style="width: 12px; height: 12px; border: 2px solid #64748b; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; display: inline-block; margin-right: 6px; vertical-align: middle;"></div>Loading full details...</div>';
|
||
detailsContainer.style.display = 'block';
|
||
try {
|
||
const res = await fetch(`/api/traces/${node.trace_id}`);
|
||
if (!res.ok) throw new Error(await res.text());
|
||
const traceDetails = await res.json();
|
||
detailsContainer.setAttribute('data-loaded', 'true');
|
||
|
||
if (traceDetails.type === 'llm_call') {
|
||
detailsContainer.innerHTML = renderLLMCallPayload(traceDetails.payload);
|
||
} else if (traceDetails.type === 'tool') {
|
||
detailsContainer.innerHTML = renderToolPayload(traceDetails.payload);
|
||
} else {
|
||
detailsContainer.innerHTML = `
|
||
<pre style="margin: 0; font-size: 0.8rem; color: #334155; font-family: monospace; white-space: pre-wrap; background-color: #f8fafc; padding: 10px; border-radius: 4px; border: 1px solid #e2e8f0; max-height: 250px; overflow-y: auto;">${escapeHtml(JSON.stringify(traceDetails.payload, null, 2))}</pre>
|
||
`;
|
||
}
|
||
} catch (err) {
|
||
detailsContainer.innerHTML = `<div style="font-size: 0.8rem; color: #dc2626;">Error: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
} else {
|
||
detailsContainer.style.display = 'block';
|
||
}
|
||
} else {
|
||
detailsContainer.style.display = 'none';
|
||
}
|
||
};
|
||
|
||
nodeRow.appendChild(header);
|
||
nodeRow.appendChild(detailsContainer);
|
||
container.appendChild(nodeRow);
|
||
|
||
if (node.children && node.children.length > 0) {
|
||
const childrenWrapper = document.createElement('div');
|
||
renderMiniTree(node.children, childrenWrapper, depth + 1, filterType);
|
||
container.appendChild(childrenWrapper);
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// Incrementally render growing stream text: append only the new tail when the
|
||
// target text extends what is already rendered; otherwise rewrite in full.
|
||
function updateStreamPreContent(pre, fullText) {
|
||
const renderedLen = pre._streamRenderedLen || 0;
|
||
const tail = pre._streamTail || '';
|
||
const canAppend = renderedLen > 0 && fullText.length >= renderedLen &&
|
||
fullText.substring(renderedLen - tail.length, renderedLen) === tail;
|
||
if (canAppend) {
|
||
if (fullText.length > renderedLen) {
|
||
pre.appendChild(document.createTextNode(fullText.substring(renderedLen)));
|
||
}
|
||
} else {
|
||
pre.textContent = fullText;
|
||
}
|
||
pre._streamRenderedLen = fullText.length;
|
||
pre._streamTail = fullText.substring(Math.max(0, fullText.length - 64));
|
||
}
|
||
|
||
// Coalesce auto-scroll to at most once per animation frame per element
|
||
function scheduleStreamAutoScroll(pre) {
|
||
if (pre._streamScrollScheduled) return;
|
||
pre._streamScrollScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
pre._streamScrollScheduled = false;
|
||
pre.scrollTop = pre.scrollHeight;
|
||
});
|
||
}
|
||
|
||
function updateStreamDisplay(traceId) {
|
||
if (!nodeStreams || !nodeStreams[traceId]) return;
|
||
if (traceId === currentSessionId || traceId === 'session') return;
|
||
|
||
let targetDiv = document.getElementById(`node-div-${traceId}`);
|
||
const streamData = nodeStreams[traceId];
|
||
|
||
if (!targetDiv && streamData && streamData.parentTraceId) {
|
||
const parentDiv = document.getElementById(`node-div-${streamData.parentTraceId}`);
|
||
if (parentDiv) {
|
||
const runningLLMs = parentDiv.querySelectorAll('.node-type-llm_call[data-status="running"]');
|
||
if (runningLLMs.length > 0) {
|
||
targetDiv = runningLLMs[runningLLMs.length - 1];
|
||
} else {
|
||
targetDiv = document.createElement('div');
|
||
targetDiv.className = 'tree-node node-type-llm_call placeholder-llm-call';
|
||
targetDiv.id = `node-div-${traceId}`;
|
||
targetDiv.setAttribute('data-status', 'running');
|
||
targetDiv.innerHTML = `
|
||
<div class="node-header type-llm_call">
|
||
<div class="node-title">
|
||
<span class="pill type-llm_call">llm_call</span>
|
||
<span><strong>Generating response...</strong></span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<span class="status-running">running</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
parentDiv.appendChild(targetDiv);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!targetDiv || !targetDiv.classList.contains('node-type-llm_call')) return;
|
||
|
||
const targetIsRunning = targetDiv.getAttribute('data-status') === 'running' || Boolean(targetDiv.querySelector('.status-running'));
|
||
if (!targetIsRunning) {
|
||
const existing = targetDiv.querySelector('.node-stream-container');
|
||
if (existing) existing.remove();
|
||
return;
|
||
}
|
||
|
||
let streamContainer = targetDiv.querySelector('.node-stream-container');
|
||
|
||
let text = '';
|
||
let thinking = '';
|
||
if (typeof streamData === 'string') {
|
||
text = streamData;
|
||
} else if (typeof streamData === 'object' && streamData !== null) {
|
||
text = streamData.text || '';
|
||
thinking = streamData.thinking || '';
|
||
}
|
||
|
||
if (!text && !thinking) {
|
||
if (streamContainer) {
|
||
streamContainer.style.display = 'none';
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!streamContainer) {
|
||
streamContainer = document.createElement('div');
|
||
streamContainer.className = 'node-stream-container';
|
||
streamContainer.style.margin = '4px 0 10px 20px';
|
||
streamContainer.style.borderLeft = '2px solid #4299e1';
|
||
streamContainer.style.backgroundColor = '#2d3748';
|
||
streamContainer.style.borderRadius = '0 6px 6px 0';
|
||
streamContainer.style.overflow = 'hidden';
|
||
streamContainer.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
|
||
streamContainer.style.transition = 'border-color 0.3s ease, box-shadow 0.3s ease';
|
||
|
||
const streamLabel = document.createElement('div');
|
||
streamLabel.style.backgroundColor = '#1a202c';
|
||
streamLabel.style.color = '#a0aec0';
|
||
streamLabel.style.padding = '4px 10px';
|
||
streamLabel.style.fontSize = '0.75rem';
|
||
streamLabel.style.fontWeight = 'bold';
|
||
streamLabel.style.textTransform = 'uppercase';
|
||
streamLabel.style.display = 'flex';
|
||
streamLabel.style.alignItems = 'center';
|
||
streamLabel.style.justifyContent = 'space-between';
|
||
streamLabel.innerHTML = `<span>LLM Output Stream</span><span style="color: #4299e1;">● Live</span>`;
|
||
streamContainer.appendChild(streamLabel);
|
||
|
||
// 1. Thinking Process Block
|
||
const labelThinking = document.createElement('div');
|
||
labelThinking.className = 'label-thinking';
|
||
labelThinking.style.color = '#a0aec0';
|
||
labelThinking.style.padding = '4px 12px';
|
||
labelThinking.style.fontSize = '0.75rem';
|
||
labelThinking.style.fontWeight = 'bold';
|
||
labelThinking.style.borderBottom = '1px solid #1a202c';
|
||
labelThinking.style.backgroundColor = '#262f3e';
|
||
labelThinking.innerText = '🧠 Thinking Process';
|
||
labelThinking.style.display = 'none';
|
||
streamContainer.appendChild(labelThinking);
|
||
|
||
const preThinking = document.createElement('pre');
|
||
preThinking.className = 'stream-thinking';
|
||
preThinking.style.margin = '0';
|
||
preThinking.style.padding = '8px 12px';
|
||
preThinking.style.backgroundColor = '#232a37';
|
||
preThinking.style.maxHeight = '150px';
|
||
preThinking.style.overflowY = 'auto';
|
||
preThinking.style.whiteSpace = 'pre-wrap';
|
||
preThinking.style.fontSize = '0.8rem';
|
||
preThinking.style.color = '#a0aec0';
|
||
preThinking.style.fontStyle = 'italic';
|
||
preThinking.style.fontFamily = "'Courier New', Courier, monospace";
|
||
preThinking.style.display = 'none';
|
||
preThinking.style.borderBottom = '1px solid #4a5568';
|
||
streamContainer.appendChild(preThinking);
|
||
|
||
// 2. Output Block
|
||
const labelText = document.createElement('div');
|
||
labelText.className = 'label-text';
|
||
labelText.style.color = '#a0aec0';
|
||
labelText.style.padding = '4px 12px';
|
||
labelText.style.fontSize = '0.75rem';
|
||
labelText.style.fontWeight = 'bold';
|
||
labelText.style.backgroundColor = '#262f3e';
|
||
labelText.innerText = '💬 Output';
|
||
labelText.style.display = 'none';
|
||
streamContainer.appendChild(labelText);
|
||
|
||
const preText = document.createElement('pre');
|
||
preText.className = 'stream-text';
|
||
preText.style.margin = '0';
|
||
preText.style.padding = '8px 12px';
|
||
preText.style.backgroundColor = 'transparent';
|
||
preText.style.maxHeight = '250px';
|
||
preText.style.overflowY = 'auto';
|
||
preText.style.whiteSpace = 'pre-wrap';
|
||
preText.style.fontSize = '0.85rem';
|
||
preText.style.color = '#e2e8f0';
|
||
preText.style.fontFamily = "'Courier New', Courier, monospace";
|
||
streamContainer.appendChild(preText);
|
||
|
||
targetDiv.appendChild(streamContainer);
|
||
}
|
||
|
||
streamContainer.style.display = 'block';
|
||
|
||
const preThinking = streamContainer.querySelector('.stream-thinking');
|
||
const labelThinking = streamContainer.querySelector('.label-thinking');
|
||
const preText = streamContainer.querySelector('.stream-text');
|
||
const labelText = streamContainer.querySelector('.label-text');
|
||
|
||
if (thinking) {
|
||
labelThinking.style.display = 'block';
|
||
preThinking.style.display = 'block';
|
||
updateStreamPreContent(preThinking, thinking);
|
||
} else {
|
||
labelThinking.style.display = 'none';
|
||
preThinking.style.display = 'none';
|
||
}
|
||
|
||
if (text) {
|
||
if (thinking) {
|
||
labelText.style.display = 'block';
|
||
} else {
|
||
labelText.style.display = 'none';
|
||
}
|
||
updateStreamPreContent(preText, text);
|
||
} else {
|
||
labelText.style.display = 'none';
|
||
updateStreamPreContent(preText, '');
|
||
}
|
||
|
||
const visiblePre = preText.style.display !== 'none' ? preText : preThinking;
|
||
scheduleStreamAutoScroll(visiblePre);
|
||
|
||
streamContainer.classList.add('streaming-active');
|
||
clearTimeout(streamContainer.streamTimeout);
|
||
streamContainer.streamTimeout = setTimeout(() => streamContainer.classList.remove('streaming-active'), 500);
|
||
}
|
||
|
||
let treeLoadInFlight = false;
|
||
let treeLoadPending = false;
|
||
let treeReloadWhenVisible = false;
|
||
|
||
async function loadTree() {
|
||
if (!currentSessionId) return;
|
||
if (document.hidden) {
|
||
// Skip rebuilding while the page is hidden; refresh once on visibilitychange
|
||
treeReloadWhenVisible = true;
|
||
return;
|
||
}
|
||
if (treeLoadInFlight) {
|
||
// Coalesce concurrent requests: run one more pass after the current load finishes
|
||
treeLoadPending = true;
|
||
return;
|
||
}
|
||
treeLoadInFlight = true;
|
||
try {
|
||
if (activeNodeId) {
|
||
try {
|
||
const resDetails = await fetch(`/api/traces/${activeNodeId}`);
|
||
if (resDetails.ok) {
|
||
activeNodeDetails = await resDetails.json();
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to update active node details', e);
|
||
}
|
||
}
|
||
const res = await fetch(`/api/sessions/${currentSessionId}/tree`);
|
||
const data = await res.json();
|
||
|
||
function scanTraces(nodes) {
|
||
if (!nodes) return;
|
||
nodes.forEach(n => {
|
||
if (n.trace_id && n.name) traceMap[n.trace_id] = n.name;
|
||
if (n.children) scanTraces(n.children);
|
||
});
|
||
}
|
||
scanTraces(data);
|
||
|
||
const container = document.getElementById('tree-container');
|
||
container.innerHTML = '';
|
||
renderTree(data, container);
|
||
|
||
// Remove any leftover stream containers from completed nodes (scoped to the tree container)
|
||
container.querySelectorAll('.tree-node:not([data-status="running"]) .node-stream-container, .node-type-tool .node-stream-container').forEach(el => el.remove());
|
||
|
||
for (let traceId in nodeStreams) {
|
||
if (traceId === currentSessionId || traceId === 'session') continue;
|
||
updateStreamDisplay(traceId);
|
||
}
|
||
|
||
try {
|
||
const bgRes = await fetch(`/api/sessions/${currentSessionId}/background_tasks`);
|
||
const bgData = await bgRes.json();
|
||
updateBackgroundTasksUI(bgData);
|
||
} catch (e) {}
|
||
} catch (err) {
|
||
console.error('Failed to load tree', err);
|
||
} finally {
|
||
treeLoadInFlight = false;
|
||
if (treeLoadPending) {
|
||
treeLoadPending = false;
|
||
loadTree();
|
||
}
|
||
}
|
||
}
|
||
|
||
function flattenVisibleNodes(nodes, showLogs) {
|
||
const allowedTypes = ['agent', 'tool', 'llm_call', 'node', 'action', 'thinking', 'raw_thinking'];
|
||
let result = [];
|
||
if (!nodes) return result;
|
||
nodes.forEach(n => {
|
||
if (showLogs || allowedTypes.includes(n.type)) {
|
||
result.push(n);
|
||
} else if (n.children && n.children.length > 0) {
|
||
result = result.concat(flattenVisibleNodes(n.children, showLogs));
|
||
}
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function renderTree(nodes, container) {
|
||
const showLogs = document.getElementById('show-logs-check').checked;
|
||
const visibleNodes = flattenVisibleNodes(nodes, showLogs);
|
||
|
||
// Temporal Clustering Algorithm: strictly isolate core agent nodes and cluster only compatible child level elements
|
||
let temporalGroups = [];
|
||
let currentGroup = [];
|
||
|
||
visibleNodes.forEach(node => {
|
||
if (currentGroup.length === 0) {
|
||
currentGroup.push(node);
|
||
} else {
|
||
const prev = currentGroup[currentGroup.length - 1];
|
||
// Isolation rule: Core agent nodes MUST NEVER be clustered horizontally and always cascade vertically.
|
||
const isClusterable = (node.type === 'llm_call' || node.type === 'tool') && node.type === prev.type;
|
||
|
||
if (isClusterable && Math.abs(node.timestamp - prev.timestamp) < 0.1) {
|
||
currentGroup.push(node);
|
||
} else {
|
||
temporalGroups.push(currentGroup);
|
||
currentGroup = [node];
|
||
}
|
||
}
|
||
});
|
||
if (currentGroup.length > 0) {
|
||
temporalGroups.push(currentGroup);
|
||
}
|
||
|
||
// Render groups based on actual temporal clustering
|
||
const nodeIndexMap = new Map();
|
||
visibleNodes.forEach((n, i) => nodeIndexMap.set(n, i));
|
||
temporalGroups.forEach(group => {
|
||
let targetContainer = container;
|
||
if (group.length > 1) {
|
||
const dash = document.createElement('div');
|
||
dash.className = 'horizontal-dashboard';
|
||
container.appendChild(dash);
|
||
targetContainer = dash;
|
||
}
|
||
|
||
group.forEach(node => {
|
||
// Find its absolute position in visibleNodes to support global ReAct stream consolidation
|
||
const absIndex = nodeIndexMap.get(node);
|
||
const isReActContinuation = node.type === 'llm_call' && absIndex > 0 && visibleNodes[absIndex - 1].type === 'tool';
|
||
|
||
const nodeDiv = document.createElement('div');
|
||
nodeDiv.className = `tree-node node-type-${node.type}`;
|
||
nodeDiv.id = `node-div-${node.trace_id}`;
|
||
nodeDiv.setAttribute('data-status', node.status || '');
|
||
|
||
if (node.type === 'raw_thinking' || node.type === 'thinking') {
|
||
const thoughtText = extractThinkingText(node.payload);
|
||
const isNative = node.type === 'thinking';
|
||
const boxColor = isNative ? '#16a34a' : '#475569';
|
||
const boxBg = isNative ? '#f0fdf4' : '#f8fafc';
|
||
const boxBorder = isNative ? '#dcfce7' : '#e2e8f0';
|
||
const label = isNative ? '🧠 Model Native Thinking' : '📝 Model Raw Thinking / Plan';
|
||
|
||
nodeDiv.innerHTML = `
|
||
<div style="background-color: ${boxBg}; border-left: 4px solid ${boxColor}; border-radius: 6px; padding: 12px 15px; border: 1px solid ${boxBorder}; border-left-width: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.02); margin: 6px 0;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<h4 style="margin: 0; color: ${boxColor}; font-size: 0.85rem; font-weight: 700; text-transform: uppercase; display: flex; align-items: center; gap: 6px;">
|
||
<span>${label}</span>
|
||
</h4>
|
||
<div style="display: flex; align-items: center; gap: 8px; font-size: 0.75rem; color: #718096;">
|
||
<span>${formatRelativeTime(node.timestamp)}</span>
|
||
<span class="status-${escapeHtml(node.status)}">${escapeHtml(node.status)}</span>
|
||
</div>
|
||
</div>
|
||
<div style="white-space: pre-wrap; font-size: 0.88rem; color: #1e293b; line-height: 1.6; font-family: inherit;">${escapeHtml(thoughtText || '(No thinking content)')}</div>
|
||
</div>
|
||
`;
|
||
|
||
const visibleChildren = flattenVisibleNodes(node.children || [], showLogs);
|
||
if (visibleChildren.length > 0) {
|
||
const childrenContainer = document.createElement('div');
|
||
renderTree(node.children, childrenContainer);
|
||
nodeDiv.appendChild(childrenContainer);
|
||
}
|
||
|
||
targetContainer.appendChild(nodeDiv);
|
||
return;
|
||
}
|
||
|
||
if (!isReActContinuation) {
|
||
const header = document.createElement('div');
|
||
header.className = `node-header type-${node.type} ${node.trace_id === activeNodeId ? 'active' : ''}`;
|
||
header.onclick = (e) => {
|
||
e.stopPropagation();
|
||
selectNode(node.trace_id);
|
||
};
|
||
|
||
const typePill = `<span class="pill type-${escapeHtml(node.type)}">${escapeHtml(node.type)}</span>`;
|
||
const statusClass = `status-${node.status}`;
|
||
|
||
header.innerHTML = `
|
||
<div class="node-title">
|
||
${typePill}
|
||
<span><strong>${escapeHtml(node.name)}</strong></span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<span style="color: #718096; font-size: 0.75rem;">${formatRelativeTime(node.timestamp)}</span>
|
||
<span class="${escapeHtml(statusClass)}">${escapeHtml(node.status)}</span>
|
||
${node.duration ? `<small style="color: #718096;">${formatNodeDurationBreakdown(node)}</small>` : ''}
|
||
</div>
|
||
`;
|
||
|
||
nodeDiv.appendChild(header);
|
||
} else {
|
||
const header = document.createElement('div');
|
||
header.className = `node-header type-${node.type} ${node.trace_id === activeNodeId ? 'active' : ''}`;
|
||
header.style.opacity = '0.8';
|
||
header.onclick = (e) => {
|
||
e.stopPropagation();
|
||
selectNode(node.trace_id);
|
||
};
|
||
|
||
const typePill = `<span class="pill type-${escapeHtml(node.type)}">↳ continuation</span>`;
|
||
const statusClass = `status-${node.status}`;
|
||
|
||
header.innerHTML = `
|
||
<div class="node-title">
|
||
${typePill}
|
||
<span style="font-style: italic; color: #718096;"><strong>${escapeHtml(node.name)}</strong></span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<span style="color: #718096; font-size: 0.75rem;">${formatRelativeTime(node.timestamp)}</span>
|
||
<span class="${escapeHtml(statusClass)}">${escapeHtml(node.status)}</span>
|
||
${node.duration ? `<small style="color: #718096;">${formatNodeDurationBreakdown(node)}</small>` : ''}
|
||
</div>
|
||
`;
|
||
|
||
nodeDiv.appendChild(header);
|
||
}
|
||
|
||
if (node.trace_id === activeNodeId && activeNodeDetails) {
|
||
const detailsDiv = document.createElement('div');
|
||
detailsDiv.className = 'detail-section';
|
||
detailsDiv.style.margin = '10px 0';
|
||
detailsDiv.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||
<h3 style="margin: 0;">Details</h3>
|
||
<a href="/api/traces/${escapeHtml(node.trace_id)}/download" target="_blank" style="font-size: 0.85rem; background-color: #2d3748; color: #fff; padding: 4px 8px; border-radius: 4px; text-decoration: none; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#4a5568'" onmouseout="this.style.backgroundColor='#2d3748'">📥 Download Raw Payload</a>
|
||
</div>
|
||
`;
|
||
|
||
if (activeNodeDetails.payload) {
|
||
if (activeNodeDetails.type === 'llm_call') {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.innerHTML = renderLLMCallPayload(activeNodeDetails.payload);
|
||
detailsDiv.appendChild(wrapper);
|
||
} else if (activeNodeDetails.type === 'tool') {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.innerHTML = renderToolPayload(activeNodeDetails.payload);
|
||
detailsDiv.appendChild(wrapper);
|
||
} else {
|
||
const pre = document.createElement('pre');
|
||
pre.innerText = JSON.stringify(activeNodeDetails.payload, null, 2);
|
||
detailsDiv.appendChild(pre);
|
||
|
||
const mediaHtml = detectAndRenderMedia(activeNodeDetails.payload);
|
||
if (mediaHtml) {
|
||
const mediaDiv = document.createElement('div');
|
||
mediaDiv.innerHTML = mediaHtml;
|
||
detailsDiv.appendChild(mediaDiv);
|
||
}
|
||
}
|
||
}
|
||
nodeDiv.appendChild(detailsDiv);
|
||
}
|
||
|
||
const visibleChildren = flattenVisibleNodes(node.children, showLogs);
|
||
if (visibleChildren.length > 0) {
|
||
const childrenContainer = document.createElement('div');
|
||
renderTree(node.children, childrenContainer);
|
||
nodeDiv.appendChild(childrenContainer);
|
||
}
|
||
|
||
targetContainer.appendChild(nodeDiv);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function selectNode(traceId) {
|
||
if (activeNodeId === traceId) {
|
||
activeNodeId = null;
|
||
activeNodeDetails = null;
|
||
loadTree();
|
||
return;
|
||
}
|
||
activeNodeId = traceId;
|
||
|
||
try {
|
||
const res = await fetch(`/api/traces/${traceId}`);
|
||
const data = await res.json();
|
||
activeNodeDetails = data;
|
||
// Refresh tree to update active class and show details inline
|
||
loadTree();
|
||
} catch (err) {
|
||
console.error('Failed to load details', err);
|
||
}
|
||
}
|
||
|
||
function detectAndRenderMedia(payload) {
|
||
let html = '';
|
||
|
||
// Helper function to scan object recursively
|
||
function scan(obj) {
|
||
if (!obj) return;
|
||
|
||
if (typeof obj === 'string') {
|
||
// Check for Base64 image
|
||
if (obj.startsWith('data:image/jpeg;base64,') || obj.startsWith('data:image/png;base64,')) {
|
||
html += `
|
||
<div class="media-container" style="margin-top: 10px; text-align: center;">
|
||
<span class="media-label" style="display: block; font-size: 0.75rem; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 5px;">🖼️ Base64 Image</span>
|
||
<div class="image-wrapper" style="border: 1px solid #e2e8f0; border-radius: 6px; background-color: #f8fafc; cursor: zoom-in; display: inline-flex; align-items: center; justify-content: center; overflow: hidden; max-height: 400px; box-shadow: 0 1px 3px rgba(0,0,0,0.03);">
|
||
<img src="${escapeHtml(obj)}" alt="Base64 Image" style="max-height: 380px; max-width: 100%; object-fit: contain;" data-zoom-img/>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
// Check for file:// URL (local sandbox files)
|
||
else if (obj.startsWith('file://')) {
|
||
html += `
|
||
<div class="media-container" style="margin-top: 10px; text-align: center;">
|
||
<span class="media-label" style="display: block; font-size: 0.75rem; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 5px;">🖼️ Tool Output Screenshot</span>
|
||
<div class="image-wrapper" style="border: 1px solid #e2e8f0; border-radius: 6px; background-color: #f8fafc; cursor: zoom-in; display: inline-flex; align-items: center; justify-content: center; overflow: hidden; max-height: 400px; box-shadow: 0 1px 3px rgba(0,0,0,0.03);">
|
||
<img src="${escapeHtml(getSafeLocalUrl(obj))}" alt="Local Media" style="max-height: 380px; max-width: 100%; object-fit: contain;" onclick="zoomImage('${escapeHtml(obj)}')"/>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
// Check for image:// reference
|
||
else if (obj.startsWith('image://')) {
|
||
const imageName = obj.replace('image://', '');
|
||
const imgUrl = `/images/${imageName}`;
|
||
html += `
|
||
<div class="media-container" style="margin-top: 10px; text-align: center;">
|
||
<span class="media-label" style="display: block; font-size: 0.75rem; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 5px;">🖼️ Image Reference: ${escapeHtml(imageName)}</span>
|
||
<div class="image-wrapper" style="border: 1px solid #e2e8f0; border-radius: 6px; background-color: #f8fafc; cursor: zoom-in; display: inline-flex; align-items: center; justify-content: center; overflow: hidden; max-height: 400px; box-shadow: 0 1px 3px rgba(0,0,0,0.03);">
|
||
<img src="${escapeHtml(imgUrl)}" alt="Referenced Image" style="max-height: 380px; max-width: 100%; object-fit: contain;" onclick="zoomImage('${escapeHtml(imgUrl)}')" onerror="this.onerror=null; this.src=''; this.parentNode.style.display='none'; this.after('Image not found on server');"/>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
// Extract any nested standard image/video paths inside the string
|
||
else {
|
||
const matches = obj.match(/[\w\-./\\]+\.(jpg|jpeg|png|mp4)/gi);
|
||
if (matches) {
|
||
matches.forEach(matchPath => {
|
||
const ext = matchPath.split('.').pop().toLowerCase();
|
||
if (ext === 'mp4') {
|
||
html += `
|
||
<div class="media-container">
|
||
<span class="media-label">Video: ${escapeHtml(matchPath)}</span>
|
||
<video controls>
|
||
<source src="/videos/${encodeURIComponent(matchPath)}" type="video/mp4">
|
||
Your browser does not support the video tag.
|
||
</video>
|
||
</div>`;
|
||
} else {
|
||
const imageName = matchPath.substring(matchPath.lastIndexOf('/') + 1);
|
||
let imageUrl = `/images/${imageName}`;
|
||
if (matchPath.startsWith('/') || matchPath.includes('\\') || matchPath.startsWith('file://')) {
|
||
imageUrl = getSafeLocalUrl(matchPath);
|
||
}
|
||
html += `
|
||
<div class="media-container" style="margin-top: 10px; text-align: center;">
|
||
<span class="media-label" style="display: block; font-size: 0.75rem; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 5px;">🖼️ Image Reference: ${escapeHtml(imageName)}</span>
|
||
<div class="image-wrapper" style="border: 1px solid #e2e8f0; border-radius: 6px; background-color: #f8fafc; cursor: zoom-in; display: inline-flex; align-items: center; justify-content: center; overflow: hidden; max-height: 400px; box-shadow: 0 1px 3px rgba(0,0,0,0.03);">
|
||
<img src="${escapeHtml(imageUrl)}" alt="Referenced Image" style="max-height: 380px; max-width: 100%; object-fit: contain;" onclick="zoomImage('${escapeHtml(imageUrl)}')" onerror="this.onerror=null; this.src=''; this.parentNode.style.display='none';"/>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} else if (typeof obj === 'object') {
|
||
for (let key in obj) {
|
||
scan(obj[key]);
|
||
}
|
||
}
|
||
}
|
||
|
||
scan(payload);
|
||
return html;
|
||
}
|
||
|
||
let _latestKnownSessionId = null;
|
||
let taskStatusCheckInFlight = false;
|
||
async function checkTaskStatus() {
|
||
// Skip when a previous poll is still pending, or while the page is hidden
|
||
if (taskStatusCheckInFlight || document.hidden) return;
|
||
taskStatusCheckInFlight = true;
|
||
try {
|
||
const res = await fetch('/api/status');
|
||
const data = await res.json();
|
||
const statusDiv = document.getElementById('run-status');
|
||
if (data.status === 'running') {
|
||
statusDiv.innerHTML = `<span style="color: #3182ce; font-weight: 600;">⚡ Active Task:</span> <span style="color: #2d3748;">${escapeHtml(data.goal)}</span> <small style="color: #a0aec0;">(PID: ${escapeHtml(data.pid)})</small>`;
|
||
document.getElementById('resume-btn').style.display = 'none';
|
||
} else if (data.status === 'completed') {
|
||
const color = data.returncode === 0 ? '#38a169' : '#e53e3e';
|
||
const text = data.returncode === 0 ? 'Successfully Completed' : `Failed (Exit ${escapeHtml(data.returncode)})`;
|
||
statusDiv.innerHTML = `<span style="color: ${color}; font-weight: 600;">✓ Task ${text}:</span> <span style="color: #2d3748;">${escapeHtml(data.goal)}</span>`;
|
||
document.getElementById('resume-btn').style.display = 'none';
|
||
} else if (data.status === 'paused') {
|
||
if (!statusDiv.innerText.includes('Task Paused')) {
|
||
statusDiv.innerHTML = `<span style="color: #e53e3e; font-weight: 600;">⏸️ Task Paused:</span> <span style="color: #2d3748;">${escapeHtml(data.goal)}</span>`;
|
||
}
|
||
document.getElementById('resume-btn').style.display = 'block';
|
||
} else {
|
||
if (!statusDiv.innerHTML || statusDiv.innerText.includes('Active Task') || statusDiv.innerText.includes('Stopping task') || statusDiv.innerText.includes('Task Paused')) {
|
||
statusDiv.innerHTML = '<span style="color: #718096;">No background task running</span>';
|
||
}
|
||
document.getElementById('resume-btn').style.display = 'none';
|
||
}
|
||
|
||
// Auto-refresh sessions list if a new active session is detected
|
||
if (data.session_id && data.session_id !== _latestKnownSessionId) {
|
||
const wasTrackingLatest = (currentSessionId === _latestKnownSessionId) || !currentSessionId;
|
||
_latestKnownSessionId = data.session_id;
|
||
loadSessions();
|
||
|
||
if (wasTrackingLatest && data.status === 'running') {
|
||
// Auto-switch to the new session so the trace live-updates without refreshing
|
||
selectSession(data.session_id, data.goal, Date.now() / 1000);
|
||
}
|
||
}
|
||
|
||
// Update background task bar only if the selected session matches the active/latest session
|
||
if (data.session_id === currentSessionId && data.background_tasks) {
|
||
updateBackgroundTasksUI(data.background_tasks);
|
||
}
|
||
} catch (e) {
|
||
// ignore connection errors
|
||
} finally {
|
||
taskStatusCheckInFlight = false;
|
||
}
|
||
}
|
||
setInterval(checkTaskStatus, 2000);
|
||
checkTaskStatus();
|
||
|
||
document.addEventListener('visibilitychange', function() {
|
||
if (document.hidden) {
|
||
// Persist any pending stream data immediately when the page is hidden
|
||
flushNodeStreamsPersist();
|
||
} else {
|
||
if (treeReloadWhenVisible) {
|
||
treeReloadWhenVisible = false;
|
||
if (activeTab === 'traces' && currentSessionId) {
|
||
loadTree();
|
||
}
|
||
}
|
||
// Resume polling immediately upon returning to the page
|
||
checkTaskStatus();
|
||
}
|
||
});
|
||
window.addEventListener('beforeunload', flushNodeStreamsPersist);
|
||
|
||
async function cleanupHistory() {
|
||
if (!confirm("Are you sure you want to clean up all database history, images, and trace files? This action cannot be undone.")) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/api/cleanup', { method: 'POST' });
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
alert("Cleanup successful!");
|
||
currentSessionId = null;
|
||
document.getElementById('session-header').innerHTML = `<h1>Select a session from the sidebar</h1>`;
|
||
document.getElementById('tree-container').innerHTML = '';
|
||
document.getElementById('details-container').innerHTML = '';
|
||
loadSessions();
|
||
} else {
|
||
alert(`Cleanup failed: ${data.detail || 'Unknown error'}`);
|
||
}
|
||
} catch (err) {
|
||
alert(`Error during cleanup: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// Initial load
|
||
loadSessions();
|
||
</script>
|
||
|
||
<!-- Image Zoom Modal -->
|
||
<div id="zoom-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(15, 23, 42, 0.85); backdrop-filter: blur(4px); z-index: 10000; justify-content: center; align-items: center; cursor: zoom-out;" onclick="closeZoomModal()">
|
||
<img id="zoom-modal-img" style="max-width: 90%; max-height: 90%; object-fit: contain; border: 2px solid #334155; border-radius: 8px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);"/>
|
||
</div>
|
||
|
||
|
||
<!-- Fixed Global Background Task Container -->
|
||
<div id="global-task-container" style="position: fixed; bottom: 0; left: 391px; right: 0; z-index: 1000; background-color: #1a202c; box-shadow: 0 -4px 20px rgba(0,0,0,0.2); display: none; flex-direction: column; border-top: 1px solid #2d3748;">
|
||
<!-- Panel Body (expandable drawer) -->
|
||
<div id="global-task-panel" style="max-height: 450px; overflow-y: auto; padding: 20px; border-bottom: 1px solid #2d3748; display: none; background-color: #111827;">
|
||
<h3 style="color: #e2e8f0; margin-top: 0; margin-bottom: 15px; font-size: 1.05rem; display: flex; align-items: center; gap: 8px;">
|
||
<span class="pill" style="background-color: #d69e2e; color: #1a202c;">Async Tasks</span>
|
||
<span>Background Tasks Tracker</span>
|
||
</h3>
|
||
<div id="background-tasks-list" style="display: flex; flex-direction: column; gap: 12px;">
|
||
<!-- Task items dynamically injected -->
|
||
</div>
|
||
</div>
|
||
<!-- Header Bar -->
|
||
<div id="global-task-bar" style="padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; background-color: #1a202c; transition: background-color 0.2s;" onclick="toggleBackgroundTaskPanel()" onmouseover="this.style.backgroundColor='#2d3748'" onmouseout="this.style.backgroundColor='#1a202c'">
|
||
<div style="display: flex; align-items: center; gap: 12px;">
|
||
<span class="pill" style="background-color: #d69e2e; color: #1a202c;">Async Tasks</span>
|
||
<span id="task-bar-text" style="font-family: monospace; color: #e2e8f0; font-size: 0.85rem;"></span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 12px;">
|
||
<div id="task-bar-spinner" class="spinner" style="width: 14px; height: 14px; border: 2px solid #e2e8f0; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; display: none;"></div>
|
||
<span id="task-panel-toggle-icon" style="color: #94a3b8; font-size: 0.8rem; font-weight: bold;">▲ Expand</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<style>
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
</style>
|
||
|
||
<!-- Floating Mini Video Player -->
|
||
<div id="floating-video-container" style="position: fixed; bottom: 60px; right: 20px; display: none; background: white; padding: 12px 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 1050; border: 1px solid #ccc;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; gap: 15px;">
|
||
<div style="font-weight: bold; font-size: 14px; color: #2c3e50; display: flex; align-items: center; gap: 6px;">📹 Run Video</div>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<button onclick="reloadSessionVideo()" style="cursor: pointer; background: none; border: none; font-size: 14px; outline: none; padding: 2px 4px;" title="Reload Video">🔄</button>
|
||
<select id="video-speed" onchange="document.getElementById('global-video').playbackRate = parseFloat(this.value)" style="font-size: 12px; padding: 2px; border-radius: 4px; border: 1px solid #ccc; background: white;">
|
||
<option value="1">1x</option>
|
||
<option value="2">2x</option>
|
||
<option value="4">4x</option>
|
||
<option value="8">8x</option>
|
||
</select>
|
||
<button onclick="toggleVideoContent()" id="video-toggle-btn-icon" style="cursor: pointer; background: none; border: none; font-size: 14px; color: #2c3e50; outline: none; padding: 2px 4px;" title="Expand / Collapse">➕</button>
|
||
</div>
|
||
</div>
|
||
<div id="video-content" style="position: relative; width: 320px; display: none; margin-top: 10px;">
|
||
<video id="global-video" width="320" height="568" controls style="border-radius: 8px;">
|
||
<source id="global-video-src" src="">
|
||
</video>
|
||
<div id="global-video-overlay" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.7); color: white; padding: 4px 8px; font-family: monospace; font-size: 16px; border-radius: 4px; pointer-events: none;">
|
||
ts: 0 s
|
||
</div>
|
||
<div id="video-error-box" style="display: none; background: #fff5f5; border: 1px solid #feb2b2; color: #c53030; padding: 20px 15px; border-radius: 8px; font-size: 0.9rem; text-align: center;">
|
||
<div style="font-size: 1.5rem; margin-bottom: 8px;">⚠️</div>
|
||
<strong style="display: block; margin-bottom: 4px;">No Recording Found</strong>
|
||
<div id="video-error-text" style="font-size: 0.8rem; color: #742a2a; line-height: 1.4;">
|
||
No video recording file was found for this session on disk or database records.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const globalVideo = document.getElementById('global-video');
|
||
const globalOverlay = document.getElementById('global-video-overlay');
|
||
if (globalVideo && globalOverlay) {
|
||
globalVideo.addEventListener('timeupdate', function() {
|
||
globalOverlay.textContent = 'ts: ' + Math.floor(globalVideo.currentTime) + ' s';
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|