kilo-loop/public/app.js
2026-09-13 13:46:28 +03:00

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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()