289 lines
9.5 KiB
JavaScript
289 lines
9.5 KiB
JavaScript
'use strict'
|
|
|
|
const $ = (id) => document.getElementById(id)
|
|
|
|
const state = {
|
|
data: null,
|
|
logSeeded: false,
|
|
lastSeq: 0,
|
|
modalIteration: null,
|
|
}
|
|
|
|
const fmt = (n) => Number(n || 0).toLocaleString('en-US')
|
|
const fmtCost = (n) => {
|
|
const v = Number(n || 0)
|
|
if (!v) return '$0.0000'
|
|
return v < 0.01 ? `$${v.toFixed(5)}` : `$${v.toFixed(4)}`
|
|
}
|
|
const fmtDur = (ms) => {
|
|
const s = Math.max(0, Math.round((ms || 0) / 1000))
|
|
const m = Math.floor(s / 60)
|
|
const h = Math.floor(m / 60)
|
|
if (h) return `${h}h ${String(m % 60).padStart(2, '0')}m`
|
|
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
|
}
|
|
const esc = (s) =>
|
|
String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
|
|
// ---------- stats ----------
|
|
function renderStats(s) {
|
|
const totals = s.totals || { tokens: {}, cost: 0, durationMs: 0 }
|
|
const done = s.iterations.length
|
|
const last = s.iterations[s.iterations.length - 1]
|
|
const percent = last?.percent ?? 0
|
|
const context = last?.contextSize ?? 0
|
|
const statuses = s.iterations.map((i) => i.status)
|
|
const cards = [
|
|
{
|
|
k: 'Progress',
|
|
v: `${percent}<small>%</small>`,
|
|
bar: percent,
|
|
},
|
|
{ k: 'Iteration', v: `${done}<small> / ${s.configuredIterations ?? s.iterations.length}</small>` },
|
|
{ k: 'Status', v: `<span style="font-size:14px">${esc(s.status)}</span>` },
|
|
{ k: 'Tokens', v: `${fmt(totals.tokens?.total)}<small> in ${fmt(totals.tokens?.input)} · out ${fmt(totals.tokens?.output)}</small>` },
|
|
{ k: 'Cost', v: fmtCost(totals.cost) },
|
|
{
|
|
k: 'Context',
|
|
v: `${fmt(context)}<small> tokens</small>`,
|
|
},
|
|
]
|
|
$('stats').innerHTML = cards
|
|
.map(
|
|
(c) => `<div class="stat"><div class="k">${c.k}</div><div class="v">${c.v}</div>${
|
|
c.bar !== undefined ? `<div class="bar"><i style="width:${Math.max(0, Math.min(100, c.bar))}%"></i></div>` : ''
|
|
}</div>`,
|
|
)
|
|
.join('')
|
|
void statuses
|
|
}
|
|
|
|
// ---------- iterations ----------
|
|
function renderIterations(s) {
|
|
const box = $('iterations')
|
|
if (!s.iterations.length) {
|
|
box.innerHTML = '<div class="empty">No iterations yet.</div>'
|
|
return
|
|
}
|
|
box.innerHTML = s.iterations
|
|
.map((it) => {
|
|
const pct = it.percent ?? 0
|
|
const status = it.status || 'pending'
|
|
const questions = (it.questions || []).length
|
|
? `<ul class="qlist">${it.questions.map((q) => `<li>${esc(q.text)}</li>`).join('')}</ul>`
|
|
: ''
|
|
const files = (it.filesChanged || []).length
|
|
? `<div class="files">${it.filesChanged.slice(0, 8).map((f) => `<span class="chip">${esc(f)}</span>`).join('')}${
|
|
it.filesChanged.length > 8 ? `<span class="chip">+${it.filesChanged.length - 8}</span>` : ''
|
|
}</div>`
|
|
: ''
|
|
return `<div class="iter ${esc(status)}">
|
|
<div class="head">
|
|
<span class="num">#${it.index}</span>
|
|
<span class="topic">${esc(it.topic || 'running…')}</span>
|
|
<span class="badge ${esc(status)}">${esc(status)}</span>
|
|
</div>
|
|
<div class="meta">
|
|
<span>${fmt(it.tokens?.total)} tok</span>
|
|
<span>${fmtCost(it.cost)}</span>
|
|
<span>ctx ${fmt(it.contextSize)}</span>
|
|
<span>${fmtDur(it.durationMs)}</span>
|
|
${it.toolCount ? `<span>${it.toolCount} tools</span>` : ''}
|
|
</div>
|
|
<div class="stage"><span class="lbl">Stage:</span> ${esc(it.stageTitle || '—')} <span class="lbl">[${esc(it.stageStatus || 'n/a')}]</span></div>
|
|
<div class="stage"><span class="lbl">Next:</span> ${esc(it.nextStage || '—')}</div>
|
|
${it.summary ? `<div class="summary">${esc(it.summary)}</div>` : ''}
|
|
${files}
|
|
${questions}
|
|
<div class="bar"><i style="width:${Math.max(0, Math.min(100, pct))}%"></i></div>
|
|
</div>`
|
|
})
|
|
.join('')
|
|
void box
|
|
}
|
|
|
|
// ---------- log ----------
|
|
function logNode(entry) {
|
|
const div = document.createElement('div')
|
|
div.className = `line ${entry.level || 'info'}`
|
|
const time = new Date(entry.t).toLocaleTimeString('en-US', { hour12: false })
|
|
const prefix = entry.level === 'section' ? '' : `<span class="t">${time}</span>`
|
|
div.innerHTML = prefix + esc(entry.text)
|
|
return div
|
|
}
|
|
|
|
function appendLog(entry) {
|
|
if (!entry || entry.seq <= state.lastSeq) return
|
|
state.lastSeq = entry.seq
|
|
const log = $('log')
|
|
const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60
|
|
log.appendChild(logNode(entry))
|
|
while (log.childElementCount > 2000) log.removeChild(log.firstChild)
|
|
if (nearBottom || entry.level === 'section') log.scrollTop = log.scrollHeight
|
|
}
|
|
|
|
function seedLog(entries) {
|
|
const log = $('log')
|
|
log.innerHTML = ''
|
|
state.lastSeq = 0
|
|
for (const e of entries || []) {
|
|
state.lastSeq = Math.max(state.lastSeq, e.seq || 0)
|
|
log.appendChild(logNode(e))
|
|
}
|
|
log.scrollTop = log.scrollHeight
|
|
}
|
|
|
|
// ---------- question modal ----------
|
|
function renderModal(s) {
|
|
const modal = $('modal')
|
|
const pq = s.pendingQuestion
|
|
if (!pq) {
|
|
modal.classList.add('hidden')
|
|
state.modalIteration = null
|
|
return
|
|
}
|
|
if (state.modalIteration === pq.iteration) return
|
|
state.modalIteration = pq.iteration
|
|
|
|
const questions = pq.questions.length ? pq.questions : [{ id: 'q1', text: 'Any corrections before the next iteration?', options: [] }]
|
|
$('modal-sub').textContent = `Iteration ${pq.iteration} finished. Answers are injected into the next session.`
|
|
$('modal-questions').innerHTML = questions
|
|
.map(
|
|
(q, i) => `<div class="q" data-i="${i}">
|
|
<label>${esc(q.text)}</label>
|
|
${
|
|
q.options?.length
|
|
? `<div class="opts">${q.options
|
|
.map((o) => `<button type="button" class="opt" data-val="${esc(o)}">${esc(o)}</button>`)
|
|
.join('')}</div>`
|
|
: ''
|
|
}
|
|
<input type="text" placeholder="Type your answer (Enter to submit)" />
|
|
</div>`,
|
|
)
|
|
.join('')
|
|
modal.classList.remove('hidden')
|
|
|
|
const first = $('modal-questions').querySelector('input')
|
|
if (first) setTimeout(() => first.focus(), 30)
|
|
|
|
$('modal-questions')
|
|
.querySelectorAll('.opt')
|
|
.forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const input = btn.closest('.q').querySelector('input')
|
|
input.value = btn.dataset.val
|
|
input.focus()
|
|
})
|
|
})
|
|
}
|
|
|
|
async function submitModal(skip) {
|
|
const questions = [...$('modal-questions').querySelectorAll('.q')]
|
|
const answers = questions.map((q) => ({
|
|
question: q.querySelector('label')?.textContent || '',
|
|
answer: skip ? '' : q.querySelector('input')?.value?.trim() || '',
|
|
}))
|
|
await fetch('/api/answer', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ answers }),
|
|
})
|
|
$('modal').classList.add('hidden')
|
|
state.modalIteration = null
|
|
}
|
|
|
|
// ---------- controls ----------
|
|
async function control(action) {
|
|
try {
|
|
await fetch('/api/control', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action }),
|
|
})
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
// ---------- state ----------
|
|
function render(s) {
|
|
state.data = s
|
|
const pauseBtn = $('btn-pause')
|
|
const paused = s.status === 'paused' || s.pauseRequested
|
|
pauseBtn.textContent = paused ? 'Resume' : 'Pause'
|
|
pauseBtn.disabled = ['done', 'done-with-errors', 'aborted', 'error'].includes(s.status)
|
|
$('btn-abort').textContent = s.abortRequested ? 'Abort armed ✓' : 'Abort after session'
|
|
$('btn-abort').classList.toggle('primary', Boolean(s.abortRequested))
|
|
|
|
const pill = $('status-pill')
|
|
pill.textContent = s.status
|
|
pill.className = `status ${s.status}`
|
|
|
|
const goal = s.goal?.title || '—'
|
|
$('run-meta').textContent = `${s.runId} · ${goal} · agent ${s.agent}${s.dryRun ? ' · DRY-RUN' : ''}`
|
|
|
|
renderStats(s)
|
|
renderIterations(s)
|
|
renderModal(s)
|
|
}
|
|
|
|
function connect() {
|
|
const es = new EventSource('/api/events')
|
|
es.onmessage = (ev) => {
|
|
let msg
|
|
try {
|
|
msg = JSON.parse(ev.data)
|
|
} catch {
|
|
return
|
|
}
|
|
if (msg.type === 'state') {
|
|
if (!state.logSeeded) {
|
|
state.logSeeded = true
|
|
seedLog(msg.state.logs)
|
|
}
|
|
render(msg.state)
|
|
} else if (msg.type === 'log') {
|
|
appendLog(msg.entry)
|
|
}
|
|
}
|
|
es.onerror = () => {
|
|
// EventSource reconnects automatically; surface it in the status pill.
|
|
const pill = $('status-pill')
|
|
if (pill) pill.textContent = 'reconnecting…'
|
|
}
|
|
}
|
|
|
|
$('btn-pause').addEventListener('click', () => {
|
|
const paused = state.data?.status === 'paused' || state.data?.pauseRequested
|
|
control(paused ? 'resume' : 'pause')
|
|
})
|
|
$('btn-abort').addEventListener('click', () => control('abort-after-current'))
|
|
$('btn-stop').addEventListener('click', () => {
|
|
if (confirm('Stop the loop now and kill the current session?')) control('stop')
|
|
})
|
|
$('btn-submit').addEventListener('click', () => submitModal(false))
|
|
$('btn-skip').addEventListener('click', () => submitModal(true))
|
|
|
|
// Enter submits inside the modal.
|
|
$('modal').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
submitModal(false)
|
|
}
|
|
})
|
|
|
|
// Terminal-style shortcuts mirror the CLI controls.
|
|
document.addEventListener('keydown', (e) => {
|
|
if (!$('modal').classList.contains('hidden')) return
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
|
const k = e.key.toLowerCase()
|
|
if (k === 'x') control('abort-after-current')
|
|
else if (k === 'p') {
|
|
const paused = state.data?.status === 'paused' || state.data?.pauseRequested
|
|
control(paused ? 'resume' : 'pause')
|
|
} else if (k === 'q' && e.shiftKey) control('stop')
|
|
})
|
|
|
|
connect()
|