Files
moeka-project/services/minecraft/src/debug/web/app.js
T

1331 lines
38 KiB
JavaScript

/**
* Debug Dashboard - Multi-View Application
* All panels visible simultaneously, focus on robustness and utility
*/
// =============================================================================
// Configuration & Constants
// =============================================================================
const CONFIG = {
MAX_LOGS: 500,
MAX_LLM_TRACES: 50,
RECONNECT_MAX_ATTEMPTS: 10,
RECONNECT_DELAY: 1000,
PING_INTERVAL: 25000,
UPDATE_THROTTLE: 100, // ms
}
const SYSTEM_STATE_MARKER = 'The following blackboard provides you with information about your current state:'
// =============================================================================
// Utility Functions
// =============================================================================
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function formatSystemMessageContent(content) {
if (typeof content !== 'string')
return content
const idx = content.indexOf(SYSTEM_STATE_MARKER)
if (idx === -1)
return content
return `===TRUNCATED===\n${content.slice(idx + SYSTEM_STATE_MARKER.length).trimStart()}`
}
function throttle(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
// =============================================================================
// WebSocket Client
// =============================================================================
class DebugClient {
constructor() {
this.ws = null
this.reconnectAttempts = 0
this.pingInterval = null
this.messageIdCounter = 0
this.eventHandlers = new Map()
}
on(eventType, handler) {
if (!this.eventHandlers.has(eventType)) {
this.eventHandlers.set(eventType, [])
}
this.eventHandlers.get(eventType).push(handler)
}
emit(eventType, data) {
const handlers = this.eventHandlers.get(eventType)
if (handlers) {
handlers.forEach((handler) => {
try {
handler(data)
}
catch (err) {
console.error(`Error in ${eventType} handler:`, err)
}
})
}
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}`
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
this.reconnectAttempts = 0
this.updateConnectionStatus(true)
this.startPing()
this.emit('connected')
}
this.ws.onclose = () => {
this.updateConnectionStatus(false)
this.stopPing()
this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('WebSocket error:', err)
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data)
}
}
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.stopPing()
}
reconnect() {
this.disconnect()
this.reconnectAttempts = 0
this.connect()
}
send(command) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const message = {
id: `${++this.messageIdCounter}`,
data: command,
timestamp: Date.now(),
}
this.ws.send(JSON.stringify(message))
}
}
handleMessage(data) {
try {
const message = JSON.parse(data)
const event = message.data
if (event.type === 'history') {
for (const historyEvent of event.payload) {
this.routeEvent(historyEvent)
}
return
}
if (event.type === 'pong') {
return
}
this.routeEvent(event)
}
catch (err) {
console.error('Failed to parse message:', err)
}
}
routeEvent(event) {
this.emit(event.type, event.payload)
}
updateConnectionStatus(connected) {
const dot = document.getElementById('connection-status')
const text = document.getElementById('status-text')
if (connected) {
dot.classList.add('connected')
text.textContent = 'Connected'
}
else {
dot.classList.remove('connected')
text.textContent = 'Disconnected'
}
}
scheduleReconnect() {
if (this.reconnectAttempts >= CONFIG.RECONNECT_MAX_ATTEMPTS) {
console.log('Max reconnect attempts reached')
return
}
this.reconnectAttempts++
const delay = CONFIG.RECONNECT_DELAY * Math.min(this.reconnectAttempts, 5)
const text = document.getElementById('status-text')
text.textContent = `Reconnecting (${this.reconnectAttempts})...`
setTimeout(() => this.connect(), delay)
}
startPing() {
this.pingInterval = setInterval(() => {
this.send({ type: 'ping', payload: { timestamp: Date.now() } })
}, CONFIG.PING_INTERVAL)
}
stopPing() {
if (this.pingInterval) {
clearInterval(this.pingInterval)
this.pingInterval = null
}
}
}
// =============================================================================
// Queue Panel
// =============================================================================
class QueuePanel {
constructor(client) {
this.client = client
this.data = { queue: [], processing: null }
this.elements = {
queueList: document.getElementById('queue-list'),
queueCount: document.getElementById('queue-count'),
processingContent: document.getElementById('processing-content'),
statQueue: document.getElementById('stat-queue'),
}
}
init() {
this.client.on('queue', data => this.update(data))
this.client.on('connected', () => this.reset())
this.render()
}
update(data) {
this.data = data
this.render()
}
reset() {
this.data = { queue: [], processing: null }
this.render()
}
render() {
const { queue, processing } = this.data
// Queue count
const queueSize = queue?.length || 0
this.elements.queueCount.textContent = queueSize
this.elements.statQueue.textContent = queueSize
// Queue list
if (queue && queue.length > 0) {
this.elements.queueList.innerHTML = queue.map((item, idx) => `
<div class="queue-item">
<span class="queue-index">#${idx + 1}</span>
<span class="queue-type">${escapeHtml(item.type)}</span>
<span class="queue-source">${escapeHtml(item.source?.id || 'unknown')}</span>
</div>
`).join('')
}
else {
this.elements.queueList.innerHTML = '<div class="empty-state">Queue empty</div>'
}
// Processing
if (processing) {
this.elements.processingContent.innerHTML = `
<div class="processing-active">
<strong>${escapeHtml(processing.type)}</strong>
<div style="color: var(--text-secondary); font-size: 11px; margin-top: 4px;">
Source: ${escapeHtml(processing.source?.type)}/${escapeHtml(processing.source?.id)}
</div>
</div>
`
}
else {
this.elements.processingContent.innerHTML = '<span style="color: var(--text-muted);">Idle</span>'
}
}
}
// =============================================================================
// Reflex Panel
// =============================================================================
class ReflexPanel {
constructor(client) {
this.client = client
this.state = null
this.elements = {
mode: document.getElementById('reflex-mode'),
statMode: document.getElementById('stat-reflex-mode'),
activeBehavior: document.getElementById('reflex-active-behavior'),
signalType: document.getElementById('reflex-signal-type'),
signalSource: document.getElementById('reflex-signal-source'),
socialSpeaker: document.getElementById('reflex-social-speaker'),
}
}
init() {
this.client.on('reflex', data => this.update(data))
this.client.on('connected', () => this.reset())
this.render()
}
update(data) {
this.state = data
this.render()
}
reset() {
this.state = null
this.render()
}
render() {
if (!this.state) {
this.elements.mode.textContent = 'unknown'
this.elements.mode.className = 'panel-badge'
if (this.elements.statMode)
this.elements.statMode.textContent = 'unknown'
this.elements.activeBehavior.textContent = 'None'
this.elements.signalType.textContent = 'None'
this.elements.signalSource.textContent = '-'
this.elements.socialSpeaker.textContent = 'None'
return
}
const { mode, activeBehaviorId, context } = this.state
// Mode
this.elements.mode.textContent = mode
this.elements.mode.className = `panel-badge ${mode === 'alert' ? 'badge-error' : (mode === 'social' ? 'badge-success' : '')}`
if (this.elements.statMode)
this.elements.statMode.textContent = mode
// Behavior
this.elements.activeBehavior.textContent = activeBehaviorId ? escapeHtml(activeBehaviorId) : 'None'
// Attention
if (context.attention?.lastSignalType) {
this.elements.signalType.textContent = escapeHtml(context.attention.lastSignalType)
this.elements.signalSource.textContent = escapeHtml(context.attention.lastSignalSourceId || '-')
}
else {
this.elements.signalType.textContent = 'None'
}
// Social
if (context.social?.lastSpeaker) {
this.elements.socialSpeaker.textContent = escapeHtml(context.social.lastSpeaker)
}
else {
this.elements.socialSpeaker.textContent = 'None'
}
}
}
// =============================================================================
// Blackboard Panel
// =============================================================================
class BlackboardPanel {
constructor(client) {
this.client = client
this.state = {}
this.elements = {
json: document.getElementById('blackboard-json'),
copyBtn: document.getElementById('blackboard-copy-btn'),
}
}
init() {
this.client.on('blackboard', data => this.update(data))
this.client.on('connected', () => this.reset())
this.elements.copyBtn.addEventListener('click', () => this.copy())
this.render()
}
update(data) {
this.state = data.state || {}
this.render()
}
reset() {
this.state = {}
this.render()
}
render() {
this.elements.json.textContent = JSON.stringify(this.state, null, 2)
}
copy() {
navigator.clipboard.writeText(JSON.stringify(this.state, null, 2))
.then(() => {
this.elements.copyBtn.textContent = '✓'
setTimeout(() => { this.elements.copyBtn.textContent = '📋' }, 1000)
})
.catch(err => console.error('Copy failed:', err))
}
}
// =============================================================================
// Logs Panel
// =============================================================================
class LogsPanel {
constructor(client) {
this.client = client
this.logs = []
this.autoScroll = true
this.paused = false
this.filter = { level: 'all', search: '' }
this.elements = {
container: document.getElementById('logs-container'),
search: document.getElementById('log-search'),
levelFilter: document.getElementById('log-level-filter'),
autoScroll: document.getElementById('auto-scroll'),
statEvents: document.getElementById('stat-events'),
}
}
init() {
this.client.on('log', data => this.addLog(data))
this.client.on('connected', () => this.reset())
this.elements.search.addEventListener('input', (e) => {
this.filter.search = e.target.value.toLowerCase()
this.renderThrottled()
})
this.elements.levelFilter.addEventListener('change', (e) => {
this.filter.level = e.target.value
this.renderThrottled()
})
this.elements.autoScroll.addEventListener('change', (e) => {
this.autoScroll = e.target.checked
})
this.renderThrottled = throttle(() => this.render(), CONFIG.UPDATE_THROTTLE)
this.render()
}
addLog(entry) {
if (this.paused)
return
this.logs.push(entry)
if (this.logs.length > CONFIG.MAX_LOGS) {
this.logs.shift()
}
this.elements.statEvents.textContent = this.logs.length
this.renderThrottled()
}
reset() {
this.logs = []
this.elements.statEvents.textContent = '0'
this.render()
}
clear() {
this.logs = []
this.elements.statEvents.textContent = '0'
this.render()
}
setPaused(paused) {
this.paused = paused
}
render() {
const filtered = this.logs.filter((log) => {
if (this.filter.level !== 'all' && log.level !== this.filter.level) {
return false
}
if (this.filter.search && !log.message.toLowerCase().includes(this.filter.search)) {
return false
}
return true
})
this.elements.container.innerHTML = filtered.map((log) => {
const time = new Date(log.timestamp).toLocaleTimeString()
const fieldsStr = log.fields ? JSON.stringify(log.fields) : ''
return `
<div class="log-entry">
<span class="log-time">${time}</span>
<span class="log-level level-${log.level}">[${log.level}]</span>
<span class="log-message">${escapeHtml(log.message)}</span>
${fieldsStr ? `<div class="log-fields">${escapeHtml(fieldsStr)}</div>` : ''}
</div>
`
}).join('')
if (this.autoScroll) {
this.elements.container.scrollTop = this.elements.container.scrollHeight
}
}
}
// =============================================================================
// LLM Panel
// =============================================================================
class LLMPanel {
constructor(client) {
this.client = client
this.traces = []
this.elements = {
list: document.getElementById('llm-list'),
count: document.getElementById('llm-count'),
statLlm: document.getElementById('stat-llm'),
}
}
init() {
this.client.on('llm', data => this.addTrace(data))
this.client.on('connected', () => this.reset())
}
addTrace(trace) {
this.traces.unshift(trace)
if (this.traces.length > CONFIG.MAX_LLM_TRACES) {
this.traces.pop()
}
this.elements.count.textContent = this.traces.length
this.elements.statLlm.textContent = this.traces.length
this.renderTrace(trace)
}
reset() {
this.traces = []
this.elements.list.innerHTML = ''
this.elements.count.textContent = '0'
this.elements.statLlm.textContent = '0'
}
renderTrace(trace) {
const time = new Date(trace.timestamp).toLocaleTimeString()
const tokens = trace.usage?.total_tokens || '?'
const duration = Number.isFinite(trace.duration) ? `${trace.duration}ms` : 'n/a'
const card = document.createElement('div')
card.className = 'llm-card'
// Messages HTML
let messagesHtml = ''
if (trace.messages && Array.isArray(trace.messages)) {
messagesHtml = trace.messages.map(msg => `
<div class="llm-message role-${msg.role || 'unknown'}">
<div class="llm-message-role">${msg.role || 'unknown'}</div>
<div class="llm-message-content">${escapeHtml(msg.role === 'system'
? formatSystemMessageContent(msg.content || '')
: (msg.content || ''))}</div>
</div>
`).join('')
}
card.innerHTML = `
<div class="llm-header">
<div class="llm-title">
<span class="llm-route">${escapeHtml(trace.route || 'unknown')}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
<div class="llm-meta">
<span>${tokens} tokens</span>
<span>${duration}</span>
</div>
</div>
<div class="llm-body">
<div class="llm-section-title">Result</div>
<div class="llm-content">${escapeHtml(trace.content || '')}</div>
<div class="llm-section-title">Messages</div>
<div class="llm-messages">
${messagesHtml || '<div class="empty-state">No messages</div>'}
</div>
<div class="llm-section-title">Usage</div>
<div class="llm-content">${JSON.stringify(trace.usage || {}, null, 2)}</div>
</div>
`
const header = card.querySelector('.llm-header')
const body = card.querySelector('.llm-body')
header.addEventListener('click', () => {
body.classList.toggle('open')
})
this.elements.list.insertBefore(card, this.elements.list.firstChild)
}
}
// =============================================================================
// Saliency Panel
// =============================================================================
class SaliencyPanel {
constructor(client) {
this.client = client
this.data = null
this.elements = {
canvas: document.getElementById('saliency-canvas'),
labels: document.getElementById('saliency-labels'),
slot: document.getElementById('saliency-slot'),
}
this.ctx = this.elements.canvas.getContext('2d')
}
init() {
this.client.on('saliency', data => this.update(data))
this.client.on('connected', () => this.reset())
}
update(data) {
this.data = data
this.render()
}
reset() {
this.data = null
this.render()
}
render() {
if (!this.data || !this.data.counters) {
this.elements.labels.innerHTML = '<div class="empty-state">No data</div>'
return
}
this.elements.slot.textContent = this.data.slot || 0
// Use all counters directly from server (fixed order)
const counters = this.data.counters
// Render labels
this.elements.labels.innerHTML = counters.map(c => `
<div class="saliency-label">
<span class="saliency-key">${escapeHtml(c.key || '')}</span>
<span class="saliency-total">(${c.total || 0})</span>
</div>
`).join('')
// Render canvas
const cols = 100
const rows = counters.length
const cellW = 6
const cellH = 16
this.elements.canvas.width = cols * cellW
this.elements.canvas.height = Math.max(1, rows) * cellH
this.ctx.fillStyle = '#0d1117'
this.ctx.fillRect(0, 0, this.elements.canvas.width, this.elements.canvas.height)
let maxCell = 0
for (const counter of counters) {
for (let i = 0; i < Math.min(cols, counter.window?.length || 0); i++) {
maxCell = Math.max(maxCell, counter.window[i] || 0)
}
}
for (let y = 0; y < rows; y++) {
const counter = counters[y]
const w = counter.window || []
const t = counter.triggers || []
for (let x = 0; x < cols; x++) {
const v = x < w.length ? (w[x] || 0) : 0
const fired = x < t.length ? (t[x] || 0) : 0
this.ctx.fillStyle = fired
? 'rgba(248, 81, 73, 0.9)'
: this.colorFor(v, maxCell)
this.ctx.fillRect(x * cellW, y * cellH, cellW - 1, cellH - 1)
}
}
}
colorFor(value, maxValue) {
// Always show a subtle color for empty cells, never fully transparent
if (value === 0) {
return 'rgba(30, 38, 50, 1)' // Dark background for empty cells
}
// Normalize value when we have data
const max = maxValue || 1
const t = Math.min(1, value / max)
const r = Math.round(88 + 80 * t)
const g = Math.round(166 + 80 * t)
const b = Math.round(255 * t)
return `rgba(${r},${g},${b},${0.3 + 0.7 * t})`
}
}
// =============================================================================
// Tools Panel
// =============================================================================
class ToolsPanel {
constructor(client) {
this.client = client
this.tools = []
this.filter = ''
this.executingTools = new Set()
this.elements = {
grid: document.getElementById('tools-grid'),
search: document.getElementById('tools-search'),
}
}
init() {
this.client.on('debug:tools_list', data => this.updateTools(data))
this.client.on('debug:tool_result', data => this.handleResult(data))
this.client.on('connected', () => this.requestTools())
this.elements.search?.addEventListener('input', (e) => {
this.filter = e.target.value.toLowerCase()
this.render()
})
}
requestTools() {
console.log('[ToolsPanel] Requesting tools...')
// Check if we already have tools to avoid re-rendering on reconnect if not needed
// But re-requesting ensures we are in sync with server capabilities
this.client.send({ type: 'request_tools' })
}
updateTools(data) {
if (data && data.tools) {
this.tools = data.tools
this.render()
}
}
render() {
if (!this.tools || this.tools.length === 0) {
this.elements.grid.innerHTML = '<div class="empty-state">Loading tools...</div>'
return
}
const filtered = this.tools.filter((tool) => {
if (!this.filter)
return true
return tool.name.toLowerCase().includes(this.filter)
|| (tool.description && tool.description.toLowerCase().includes(this.filter))
})
if (filtered.length === 0) {
this.elements.grid.innerHTML = '<div class="empty-state">No tools match filter</div>'
return
}
// Don't nuke usage of existing DOM elements if possible to preserve form state?
// For simplicity, re-render is fine for this debug tool.
this.elements.grid.innerHTML = filtered.map(tool => this.renderCard(tool)).join('')
// Attach event listeners
filtered.forEach((tool) => {
const card = document.getElementById(`tool-card-${tool.name}`)
const executeBtn = card?.querySelector('.btn-execute')
executeBtn?.addEventListener('click', () => this.executeTool(tool))
})
}
renderCard(tool) {
const isExecuting = this.executingTools.has(tool.name)
const cardState = isExecuting ? 'executing' : ''
const paramCount = tool.params.length
return `
<div id="tool-card-${tool.name}" class="tool-card ${cardState}">
<div class="tool-card-header">
<span class="tool-name">${escapeHtml(tool.name)}</span>
${paramCount > 0 ? `<span class="tool-badge">${paramCount} param${paramCount > 1 ? 's' : ''}</span>` : ''}
</div>
<div class="tool-description">${escapeHtml(tool.description || '')}</div>
${this.renderParams(tool)}
<div class="tool-actions">
<button class="btn-execute" ${isExecuting ? 'disabled' : ''}>
${isExecuting ? 'Executing...' : 'Execute'}
</button>
</div>
<div id="result-${tool.name}" class="tool-result hidden"></div>
</div>
`
}
renderParams(tool) {
if (tool.params.length === 0)
return ''
return `
<div class="tool-params">
${tool.params.map(param => `
<div class="param-group">
<label class="param-label">${escapeHtml(param.name)} (${param.type})</label>
<input
type="${param.type === 'number' ? 'number' : 'text'}"
class="param-input"
data-param="${param.name}"
${param.min !== undefined ? `min="${param.min}"` : ''}
${param.max !== undefined ? `max="${param.max}"` : ''}
${param.default !== undefined ? `value="${param.default}"` : ''}
placeholder="${escapeHtml(param.description || '')}"
/>
</div>
`).join('')}
</div>
`
}
executeTool(tool) {
const card = document.getElementById(`tool-card-${tool.name}`)
if (!card)
return
// Collect parameter values
const params = {}
const inputs = card.querySelectorAll('.param-input')
for (const input of inputs) {
const paramName = input.dataset.param
let value = input.value
// Convert to appropriate type based on definition
const paramDef = tool.params.find(p => p.name === paramName)
if (paramDef) {
if (paramDef.type === 'number') {
if (value === '') {
// Handle empty number input if needed?
}
else {
value = Number.parseFloat(value)
if (isNaN(value)) {
this.showResult(tool.name, { error: `Invalid number for ${paramName}` }, true)
return
}
}
}
}
// Simple type conversion could be improved but sufficient for now
params[paramName] = value
}
// Mark as executing
this.executingTools.add(tool.name)
this.updateCardState(tool.name, 'executing')
this.hideResult(tool.name)
// Send command to server
this.client.send({
type: 'execute_tool',
payload: {
toolName: tool.name,
params,
},
})
}
handleResult(data) {
const { toolName, result, error } = data
this.executingTools.delete(toolName)
this.updateCardState(toolName, error ? 'error' : 'success')
this.showResult(toolName, data, !!error)
if (!error) {
setTimeout(() => {
// Reset state visual but keep result visible for a bit?
// Or remove success styling
const card = document.getElementById(`tool-card-${toolName}`)
if (card) {
card.classList.remove('success')
// Don't auto-hide result immediately, maybe user wants to read it
}
}, 3000)
}
}
updateCardState(toolName, state) {
const card = document.getElementById(`tool-card-${toolName}`)
if (!card)
return
card.classList.remove('executing', 'success', 'error')
if (state)
card.classList.add(state)
const btn = card.querySelector('.btn-execute')
if (btn) {
if (state === 'executing') {
btn.disabled = true
btn.textContent = 'Executing...'
}
else {
btn.disabled = false
btn.textContent = 'Execute'
}
}
}
showResult(toolName, data, isError) {
const resultEl = document.getElementById(`result-${toolName}`)
if (!resultEl)
return
resultEl.classList.remove('hidden')
const label = isError ? 'Error' : 'Result'
const content = isError ? data.error : data.result
resultEl.innerHTML = `
<span class="result-label">${label}</span>
<div class="result-content">${escapeHtml(content || 'No result')}</div>
`
}
hideResult(toolName) {
const resultEl = document.getElementById(`result-${toolName}`)
if (resultEl) {
resultEl.classList.add('hidden')
}
}
}
class TimelinePanel {
constructor(client) {
this.client = client
this.events = []
this.filter = { type: 'all', search: '' }
this.selectedTraceId = null
this.elements = {
list: document.getElementById('timeline-list'),
container: document.getElementById('timeline-container'),
search: document.getElementById('timeline-search'),
typeFilter: document.getElementById('timeline-type-filter'),
clearBtn: document.getElementById('timeline-clear-btn'),
detail: document.getElementById('trace-detail'),
detailTitle: document.getElementById('trace-detail-title'),
detailContent: document.getElementById('trace-detail-content'),
detailClose: document.getElementById('trace-detail-close'),
}
}
init() {
this.client.on('trace', data => this.addEvent(data))
this.client.on('trace_batch', (data) => {
if (data.events) {
data.events.forEach(e => this.addEvent(e))
}
})
this.client.on('connected', () => this.reset())
this.elements.search?.addEventListener('input', (e) => {
this.filter.search = e.target.value.toLowerCase()
this.renderThrottled()
})
this.elements.typeFilter?.addEventListener('change', (e) => {
this.filter.type = e.target.value
this.renderThrottled()
})
this.elements.clearBtn?.addEventListener('click', () => this.clear())
this.elements.detailClose?.addEventListener('click', () => this.hideDetail())
this.renderThrottled = throttle(() => this.render(), CONFIG.UPDATE_THROTTLE)
}
addEvent(event) {
this.events.push(event)
if (this.events.length > 1000) {
this.events.shift()
}
this.renderThrottled()
}
reset() {
this.events = []
this.selectedTraceId = null
this.hideDetail()
this.render()
}
clear() {
this.reset()
}
render() {
const filtered = this.events.filter(e => this.matchesFilter(e))
const recent = filtered.slice(-200) // Show last 200
if (recent.length === 0) {
this.elements.list.innerHTML = '<div class="empty-state">No events</div>'
return
}
this.elements.list.innerHTML = recent.map(e => this.renderEvent(e)).join('')
// Attach click handlers
this.elements.list.querySelectorAll('.timeline-event').forEach((el) => {
el.addEventListener('click', () => {
const traceId = el.dataset.traceId
this.showTraceDetail(traceId)
})
})
// Auto-scroll
this.elements.container.scrollTop = this.elements.container.scrollHeight
}
matchesFilter(event) {
if (this.filter.type !== 'all' && !event.type.startsWith(this.filter.type)) {
return false
}
if (this.filter.search) {
const searchStr = `${event.type} ${JSON.stringify(event.payload)}`.toLowerCase()
if (!searchStr.includes(this.filter.search)) {
return false
}
}
return true
}
renderEvent(event) {
const time = new Date(event.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
})
const isSignal = event.type.startsWith('signal:')
const isRaw = event.type.startsWith('raw:')
const typeClass = isSignal ? 'type-signal' : (isRaw ? 'type-raw' : 'type-other')
// Extract useful info from payload
let info = ''
if (event.payload) {
const p = event.payload
if (p.description)
info = p.description
else if (p.displayName)
info = p.displayName
else if (p.entityType)
info = p.entityType
}
const hasParent = event.parentId ? 'has-parent' : ''
return `
<div class="timeline-event ${typeClass} ${hasParent}"
data-trace-id="${event.traceId}"
data-event-id="${event.id}">
<span class="timeline-time">${time}</span>
<span class="timeline-type">${escapeHtml(event.type)}</span>
${info ? `<span class="timeline-info">${escapeHtml(info)}</span>` : ''}
<span class="timeline-trace" title="Trace: ${event.traceId}">⎘</span>
</div>
`
}
showTraceDetail(traceId) {
this.selectedTraceId = traceId
const traceEvents = this.events.filter(e => e.traceId === traceId)
if (traceEvents.length === 0) {
return
}
// Build event tree
const tree = this.buildEventTree(traceEvents)
this.elements.detailTitle.textContent = `Trace: ${traceId.slice(0, 8)}...`
this.elements.detailContent.innerHTML = this.renderEventTree(tree)
this.elements.detail.classList.remove('hidden')
// Highlight in main list
this.elements.list.querySelectorAll('.timeline-event').forEach((el) => {
el.classList.toggle('selected', el.dataset.traceId === traceId)
})
}
hideDetail() {
this.elements.detail?.classList.add('hidden')
this.selectedTraceId = null
this.elements.list?.querySelectorAll('.timeline-event.selected').forEach((el) => {
el.classList.remove('selected')
})
}
buildEventTree(events) {
const eventMap = new Map()
const roots = []
// Index all events
events.forEach(e => eventMap.set(e.id, { event: e, children: [] }))
// Build tree
events.forEach((e) => {
const node = eventMap.get(e.id)
if (e.parentId && eventMap.has(e.parentId)) {
eventMap.get(e.parentId).children.push(node)
}
else {
roots.push(node)
}
})
return roots
}
renderEventTree(nodes, depth = 0) {
return nodes.map((node) => {
const e = node.event
const indent = depth * 16
const time = new Date(e.timestamp).toLocaleTimeString('en-US', { hour12: false })
return `
<div class="trace-tree-node" style="padding-left: ${indent}px">
<div class="trace-node-header">
${depth > 0 ? '<span class="trace-connector">↳</span>' : ''}
<span class="trace-node-type">${escapeHtml(e.type)}</span>
<span class="trace-node-time">${time}</span>
</div>
<div class="trace-node-payload">${escapeHtml(JSON.stringify(e.payload, null, 2))}</div>
${node.children.length > 0 ? this.renderEventTree(node.children, depth + 1) : ''}
</div>
`
}).join('')
}
}
// =============================================================================
// Application
// =============================================================================
// =============================================================================
// Layout Manager (Resizing & Maximizing)
// =============================================================================
class LayoutManager {
constructor() {
this.root = document.documentElement
this.activeSplitter = null
this.startPos = 0
this.startSize = 0
}
init() {
this.setupSplitters()
this.setupMaximizeButtons()
}
setupSplitters() {
const splitters = [
{ id: 'splitter-v1', var: '--col-left', type: 'v' },
{ id: 'splitter-h1', var: '--row-1', type: 'h' },
{ id: 'splitter-h2', var: '--row-2', type: 'h' },
{ id: 'splitter-h3', var: '--row-3', type: 'h' },
]
splitters.forEach((config) => {
const el = document.getElementById(config.id)
if (!el)
return
el.addEventListener('mousedown', (e) => {
this.activeSplitter = { el, ...config }
this.startPos = config.type === 'v' ? e.clientX : e.clientY
const style = getComputedStyle(this.root)
const raw = style.getPropertyValue(config.var)
const parsed = Number.parseFloat(raw)
const fallbackSize = () => {
if (config.var === '--col-left')
return document.getElementById('left-column')?.getBoundingClientRect().width
if (config.var === '--row-1')
return document.getElementById('logs-section')?.getBoundingClientRect().height
if (config.var === '--row-2')
return document.getElementById('timeline-section')?.getBoundingClientRect().height
if (config.var === '--row-3')
return document.getElementById('llm-section')?.getBoundingClientRect().height
return undefined
}
this.startSize = Number.isFinite(parsed) ? parsed : (fallbackSize() || 200)
el.classList.add('dragging')
document.body.style.cursor = config.type === 'v' ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none' // Prevent text selection
})
})
document.addEventListener('mousemove', e => this.handleDrag(e))
document.addEventListener('mouseup', () => this.handleDragEnd())
}
handleDrag(e) {
if (!this.activeSplitter)
return
const currentPos = this.activeSplitter.type === 'v' ? e.clientX : e.clientY
const delta = currentPos - this.startPos
const newSize = Math.max(50, this.startSize + delta) // Min 50px
this.root.style.setProperty(this.activeSplitter.var, `${newSize}px`)
}
handleDragEnd() {
if (!this.activeSplitter)
return
this.activeSplitter.el.classList.remove('dragging')
this.activeSplitter = null
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
setupMaximizeButtons() {
document.querySelectorAll('.maximize-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation() // Prevent bubbling
const panel = btn.closest('.panel')
if (!panel)
return
panel.classList.toggle('maximized')
const isMaximized = panel.classList.contains('maximized')
btn.textContent = isMaximized ? '✕' : '⤢'
btn.title = isMaximized ? 'Restore' : 'Maximize'
})
})
}
}
class DebugApp {
constructor() {
this.client = new DebugClient()
this.layoutManager = new LayoutManager()
this.queuePanel = new QueuePanel(this.client)
this.reflexPanel = new ReflexPanel(this.client)
this.blackboardPanel = new BlackboardPanel(this.client)
this.logsPanel = new LogsPanel(this.client)
this.llmPanel = new LLMPanel(this.client)
this.saliencyPanel = new SaliencyPanel(this.client)
this.timelinePanel = new TimelinePanel(this.client)
this.toolsPanel = new ToolsPanel(this.client)
this.panels = {
queue: this.queuePanel,
reflex: this.reflexPanel,
blackboard: this.blackboardPanel,
logs: this.logsPanel,
llm: this.llmPanel,
saliency: this.saliencyPanel,
timeline: this.timelinePanel,
tools: this.toolsPanel,
}
this.paused = false
}
init() {
// Initialize layout
this.layoutManager.init()
// Tabs
this.setupTabs()
// Initialize all panels
Object.values(this.panels).forEach(panel => panel.init())
// Setup controls
document.getElementById('clear-logs-btn').addEventListener('click', () => {
this.panels.logs.clear()
})
document.getElementById('pause-btn').addEventListener('click', (e) => {
this.paused = !this.paused
this.panels.logs.setPaused(this.paused)
e.target.textContent = this.paused ? 'Resume' : 'Pause'
})
document.getElementById('reconnect-btn').addEventListener('click', () => {
this.client.reconnect()
})
// Connect
this.client.connect()
}
setupTabs() {
const tabs = Array.from(document.querySelectorAll('.tab-button'))
const views = Array.from(document.querySelectorAll('.view'))
const activate = (target) => {
tabs.forEach(btn => btn.classList.toggle('active', btn.dataset.target === target))
views.forEach(view => view.classList.toggle('active', view.id === target))
}
tabs.forEach((btn) => {
btn.addEventListener('click', () => activate(btn.dataset.target))
})
// Default
activate('main-view')
}
}
// =============================================================================
// Bootstrap
// =============================================================================
document.addEventListener('DOMContentLoaded', () => {
const app = new DebugApp()
app.init()
window.debugApp = app // For debugging
})