Beacon / src /ui.html
Ig0tU
feat: beacon dashboard — space cards, live preview, output viewer
bb92ce1
Raw
History Blame Contribute Delete
13.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>beacon — acecalisto3</title>
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js"></script>
<style>
:root {
--bg: #0a0a0f;
--surface: #12121a;
--border: #1e1e2e;
--accent: #7df;
--accent2: #afc;
--text: #dde;
--muted: #556;
--error: #f77;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'SF Mono', 'Fira Code', monospace; min-height: 100vh; }
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 1rem;
}
header h1 { font-size: 1.1rem; color: var(--accent); letter-spacing: .1em; }
header span { color: var(--muted); font-size: .85rem; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent2); box-shadow: 0 0 8px var(--accent2); animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
main { display: grid; grid-template-columns: 340px 1fr; min-height: calc(100vh - 65px); }
/* ── Left: space cards ── */
.sidebar { border-right: 1px solid var(--border); padding: 1.5rem 1rem; overflow-y: auto; }
.sidebar h2 { font-size: .7rem; color: var(--muted); letter-spacing: .15em; text-transform: uppercase; margin-bottom: 1rem; padding: 0 .5rem; }
.card {
border: 1px solid var(--border);
border-radius: 8px;
padding: .85rem 1rem;
margin-bottom: .6rem;
cursor: pointer;
transition: border-color .15s, background .15s;
}
.card:hover { border-color: var(--accent); background: #12121a; }
.card.active { border-color: var(--accent); background: #0d1a1f; }
.card-cap { font-size: .65rem; color: var(--accent); letter-spacing: .12em; text-transform: uppercase; margin-bottom: .3rem; }
.card-name { font-size: .9rem; color: var(--text); margin-bottom: .25rem; }
.card-desc { font-size: .75rem; color: var(--muted); line-height: 1.4; }
/* ── Right: active panel ── */
.panel { display: flex; flex-direction: column; padding: 2rem; gap: 1.5rem; overflow-y: auto; }
.empty-state { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--muted); gap: .5rem; }
.empty-state .icon { font-size: 2.5rem; opacity: .3; }
.panel-header h2 { font-size: 1rem; color: var(--text); margin-bottom: .25rem; }
.panel-header p { font-size: .8rem; color: var(--muted); }
.badge { display: inline-block; font-size: .6rem; background: #0d1a1f; color: var(--accent); border: 1px solid var(--accent); border-radius: 4px; padding: .1rem .4rem; letter-spacing: .1em; text-transform: uppercase; margin-right: .4rem; }
.prompt-area label { display: block; font-size: .7rem; color: var(--muted); letter-spacing: .1em; text-transform: uppercase; margin-bottom: .5rem; }
textarea {
width: 100%; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-family: inherit; font-size: .9rem;
padding: .75rem 1rem; resize: vertical; min-height: 80px; line-height: 1.5;
transition: border-color .15s;
}
textarea:focus { outline: none; border-color: var(--accent); }
textarea::placeholder { color: var(--muted); }
/* curl preview */
.preview-box { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 1rem; }
.preview-box label { display: block; font-size: .65rem; color: var(--muted); letter-spacing: .12em; text-transform: uppercase; margin-bottom: .6rem; }
.preview-box pre { font-size: .78rem; color: var(--accent2); line-height: 1.6; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
.preview-box .resolving { color: var(--muted); font-style: italic; font-size: .8rem; }
.actions { display: flex; gap: .75rem; align-items: center; }
button {
background: transparent; border: 1px solid var(--accent); color: var(--accent);
border-radius: 6px; padding: .55rem 1.2rem; font-family: inherit; font-size: .85rem;
cursor: pointer; letter-spacing: .05em; transition: background .15s, color .15s;
}
button:hover { background: var(--accent); color: var(--bg); }
button:disabled { opacity: .4; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--bg); }
button.primary:hover { background: #9ef; }
.status { font-size: .8rem; color: var(--muted); }
/* output */
.output-box { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.output-header { padding: .75rem 1rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.output-header span { font-size: .7rem; color: var(--muted); letter-spacing: .1em; text-transform: uppercase; }
.output-header .dl-links { display: flex; gap: .5rem; }
.output-header a { font-size: .75rem; color: var(--accent); text-decoration: none; border: 1px solid var(--border); border-radius: 4px; padding: .2rem .6rem; }
.output-header a:hover { border-color: var(--accent); }
.output-body { padding: 1rem; }
model-viewer { width: 100%; height: 400px; background: #080810; border-radius: 0 0 8px 8px; --poster-color: transparent; }
.output-body img { max-width: 100%; border-radius: 4px; }
.output-body audio { width: 100%; }
.output-body pre { font-size: .8rem; line-height: 1.6; color: var(--accent2); white-space: pre-wrap; max-height: 400px; overflow-y: auto; }
.output-body video { max-width: 100%; border-radius: 4px; }
.error-msg { color: var(--error); font-size: .85rem; padding: .5rem 0; }
</style>
</head>
<body>
<header>
<div class="dot"></div>
<h1>📡 beacon</h1>
<span id="owner-tag">acecalisto3 · loading spaces...</span>
</header>
<main>
<aside class="sidebar">
<h2>available spaces</h2>
<div id="cards"></div>
</aside>
<div class="panel" id="panel">
<div class="empty-state">
<div class="icon"></div>
<div>select a space to begin</div>
</div>
</div>
</main>
<script>
const BASE = window.location.origin
let activeSpace = null
let previewTimer = null
let currentOutputUrls = []
// ── Load space cards ──────────────────────────────────────────────────────────
async function loadSpaces() {
const res = await fetch(`${BASE}/space/list`)
const spaces = await res.json()
document.getElementById('owner-tag').textContent = `acecalisto3 · ${spaces.length} spaces`
const cards = document.getElementById('cards')
spaces.forEach(s => {
const el = document.createElement('div')
el.className = 'card'
el.dataset.id = s.id
el.innerHTML = `
<div class="card-cap">${s.capability}</div>
<div class="card-name">${s.name}</div>
<div class="card-desc">${s.description}</div>
`
el.addEventListener('click', () => activateSpace(s, el))
cards.appendChild(el)
})
}
// ── Activate a space card ─────────────────────────────────────────────────────
function activateSpace(space, el) {
document.querySelectorAll('.card').forEach(c => c.classList.remove('active'))
el.classList.add('active')
activeSpace = space
currentOutputUrls = []
renderPanel(space)
}
function renderPanel(space) {
const inputHints = space.inputs.map(i =>
`${i.name}${i.required ? ' *' : ''}: ${i.description || i.type}`
).join(' · ')
document.getElementById('panel').innerHTML = `
<div class="panel-header">
<h2>
<span class="badge">${space.capability}</span>
${space.id}
</h2>
<p>${space.description}</p>
${inputHints ? `<p style="margin-top:.4rem;font-size:.75rem;color:var(--muted)">inputs: ${inputHints}</p>` : ''}
</div>
<div class="prompt-area">
<label>what do you want?</label>
<textarea id="prompt-input" placeholder="describe your request in plain language — paste URLs directly in here too" rows="3"></textarea>
</div>
<div class="preview-box" id="preview-box">
<label>structured request preview</label>
<div class="resolving">start typing to see the resolved call...</div>
</div>
<div class="actions">
<button class="primary" id="execute-btn" disabled>Execute →</button>
<span class="status" id="status-msg"></span>
</div>
<div id="output-area"></div>
`
document.getElementById('prompt-input').addEventListener('input', onPromptInput)
document.getElementById('execute-btn').addEventListener('click', onExecute)
}
// ── Live preview (debounced) ──────────────────────────────────────────────────
function onPromptInput(e) {
const prompt = e.target.value.trim()
const previewBox = document.getElementById('preview-box')
const execBtn = document.getElementById('execute-btn')
clearTimeout(previewTimer)
execBtn.disabled = true
if (!prompt) {
previewBox.querySelector('div').className = 'resolving'
previewBox.querySelector('div').textContent = 'start typing to see the resolved call...'
return
}
previewBox.querySelector('div').className = 'resolving'
previewBox.querySelector('div').textContent = 'resolving...'
previewTimer = setTimeout(async () => {
try {
const res = await fetch(`${BASE}/space/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space: activeSpace.id, prompt })
})
const data = await res.json()
if (data.error) throw new Error(data.error)
const preview = document.getElementById('preview-box')
preview.innerHTML = `
<label>structured request preview</label>
<pre>${escHtml(data.curl)}</pre>
<pre style="margin-top:.5rem;color:var(--muted);font-size:.72rem">${escHtml(JSON.stringify(data.structured, null, 2))}</pre>
`
execBtn.disabled = false
} catch (err) {
const preview = document.getElementById('preview-box')
preview.innerHTML = `<label>structured request preview</label><div class="resolving">${escHtml(err.message)}</div>`
}
}, 600)
}
// ── Execute ───────────────────────────────────────────────────────────────────
async function onExecute() {
const prompt = document.getElementById('prompt-input').value.trim()
const btn = document.getElementById('execute-btn')
const status = document.getElementById('status-msg')
btn.disabled = true
status.textContent = 'calling space...'
try {
const res = await fetch(`${BASE}/space/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space: activeSpace.id, prompt })
})
const data = await res.json()
if (data.error) throw new Error(data.error)
status.textContent = 'done'
currentOutputUrls = data.output_urls ?? []
renderOutput(data)
} catch (err) {
status.textContent = ''
document.getElementById('output-area').innerHTML =
`<div class="error-msg">⚠ ${escHtml(err.message)}</div>`
}
btn.disabled = false
}
// ── Render output ─────────────────────────────────────────────────────────────
function renderOutput(data) {
const urls = data.output_urls ?? []
const area = document.getElementById('output-area')
const dlLinks = urls.map(u => {
const filename = u.split('/').pop().split('?')[0] || 'output'
return `<a href="${escHtml(u)}" download="${escHtml(filename)}" target="_blank">↓ ${escHtml(filename)}</a>`
}).join('')
let body = ''
for (const url of urls) {
const ext = url.split('.').pop().toLowerCase().split('?')[0]
if (['glb', 'gltf', 'obj'].includes(ext)) {
body += `<model-viewer src="${escHtml(url)}" ar auto-rotate camera-controls shadow-intensity="1"></model-viewer>`
} else if (['png','jpg','jpeg','webp','gif'].includes(ext)) {
body += `<img src="${escHtml(url)}" alt="output">`
} else if (['mp4','webm'].includes(ext)) {
body += `<video src="${escHtml(url)}" controls autoplay muted></video>`
} else if (['mp3','wav','ogg','flac'].includes(ext)) {
body += `<audio src="${escHtml(url)}" controls></audio>`
} else {
body += `<pre><a href="${escHtml(url)}" target="_blank">${escHtml(url)}</a></pre>`
}
}
// If no file outputs, show raw data
if (!body) {
const raw = JSON.stringify(data.outputs ?? data, null, 2)
body = `<pre>${escHtml(raw)}</pre>`
}
area.innerHTML = `
<div class="output-box">
<div class="output-header">
<span>output</span>
<div class="dl-links">${dlLinks}</div>
</div>
<div class="output-body">${body}</div>
</div>
`
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
}
loadSpaces()
</script>
</body>
</html>