honcho-api / src /static /dashboard.html
=
Claude Opus 4.8
Route all LLM work through OpenRouter; add memory dashboard
6870279
Raw History Blame Contribute Delete
23.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Honcho Memory Dashboard</title>
<style>
:root{
--bg:#0e1117; --panel:#161b22; --panel2:#1c2333; --border:#2a3140;
--text:#e6edf3; --muted:#8b97a7; --accent:#a371f7; --accent2:#58a6ff;
--green:#3fb950; --amber:#d29922; --red:#f85149; --chip:#21262d;
--radius:10px; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.45}
button{font-family:inherit;cursor:pointer}
input{font-family:inherit}
a{color:var(--accent2);text-decoration:none}
/* Layout */
header{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel);
border-bottom:1px solid var(--border);position:sticky;top:0;z-index:20;flex-wrap:wrap}
header h1{font-size:15px;margin:0;font-weight:600;display:flex;align-items:center;gap:8px;white-space:nowrap}
header .brand-emoji{font-size:18px}
.conn{display:flex;align-items:center;gap:8px;flex:1;min-width:280px}
.conn input{background:var(--bg);border:1px solid var(--border);color:var(--text);
padding:7px 9px;border-radius:8px;font-size:12.5px}
.conn input.base{flex:1;min-width:200px}
.conn input.key{width:150px}
.btn{background:var(--accent);color:#fff;border:none;padding:7px 14px;border-radius:8px;font-size:12.5px;font-weight:600}
.btn:hover{filter:brightness(1.08)}
.btn.ghost{background:var(--chip);color:var(--text);border:1px solid var(--border)}
.dot{width:9px;height:9px;border-radius:50%;background:var(--muted);flex:none}
.dot.ok{background:var(--green);box-shadow:0 0 6px var(--green)}
.dot.err{background:var(--red);box-shadow:0 0 6px var(--red)}
.status-text{font-size:12px;color:var(--muted);white-space:nowrap}
main{display:grid;grid-template-columns:240px 1fr;height:calc(100vh - 53px)}
aside{border-right:1px solid var(--border);background:var(--panel);overflow:auto;padding:10px}
aside h2{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:6px 6px 8px}
.ws-item{padding:9px 10px;border-radius:8px;cursor:pointer;display:flex;flex-direction:column;gap:2px;margin-bottom:3px}
.ws-item:hover{background:var(--panel2)}
.ws-item.active{background:var(--accent);color:#fff}
.ws-item .name{font-weight:600;font-size:13.5px}
.ws-item .meta{font-size:11px;color:var(--muted)}
.ws-item.active .meta{color:rgba(255,255,255,.8)}
.content{overflow:auto;padding:18px 22px}
.empty-pane{height:100%;display:flex;align-items:center;justify-content:center;color:var(--muted);
flex-direction:column;gap:10px;text-align:center}
.empty-pane .big{font-size:34px;opacity:.5}
/* Queue banner */
.queue{background:linear-gradient(135deg,var(--panel2),var(--panel));border:1px solid var(--border);
border-radius:var(--radius);padding:14px 16px;margin-bottom:18px}
.queue .row{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.queue .title{font-weight:600;display:flex;align-items:center;gap:8px}
.queue .nums{font-size:12px;color:var(--muted)}
.bar{height:9px;background:var(--bg);border-radius:6px;overflow:hidden;display:flex}
.bar .done{background:var(--green)}
.bar .prog{background:var(--amber)}
.queue .legend{display:flex;gap:16px;margin-top:8px;font-size:11.5px;color:var(--muted);flex-wrap:wrap}
.queue .legend b{color:var(--text)}
.pulse{animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
/* Tabs */
.tabs{display:flex;gap:4px;border-bottom:1px solid var(--border);margin-bottom:16px}
.tab{padding:9px 16px;border:none;background:none;color:var(--muted);font-size:13.5px;font-weight:600;
border-bottom:2px solid transparent;margin-bottom:-1px}
.tab:hover{color:var(--text)}
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab .count{display:inline-block;background:var(--chip);color:var(--muted);font-size:11px;
padding:1px 7px;border-radius:10px;margin-left:6px}
/* Cards / lists */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.card{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:14px;cursor:pointer;transition:border-color .12s}
.card:hover{border-color:var(--accent)}
.card .h{font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px;word-break:break-all}
.card .sub{font-size:11.5px;color:var(--muted);margin-top:5px}
.avatar{width:26px;height:26px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:700;color:#fff}
.badge{font-size:10.5px;padding:2px 8px;border-radius:10px;background:var(--chip);color:var(--muted)}
.badge.green{background:rgba(63,185,80,.15);color:var(--green)}
.session-row{display:flex;align-items:center;justify-content:space-between;background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:11px 14px;margin-bottom:8px;cursor:pointer}
.session-row:hover{border-color:var(--accent)}
.session-row .left{display:flex;flex-direction:column;gap:3px;min-width:0}
.session-row .sid{font-weight:600;font-family:var(--mono);font-size:13px;word-break:break-all}
.session-row .sub{font-size:11.5px;color:var(--muted)}
/* Detail / drawer */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:30;display:none}
.overlay.open{display:block}
.drawer{position:fixed;top:0;right:0;height:100%;width:min(680px,100%);background:var(--panel);
border-left:1px solid var(--border);z-index:31;transform:translateX(100%);transition:transform .18s ease;
display:flex;flex-direction:column}
.drawer.open{transform:translateX(0)}
.drawer header{position:static;background:var(--panel2)}
.drawer .dtitle{font-weight:700;font-size:15px;flex:1;display:flex;align-items:center;gap:9px;word-break:break-all}
.drawer .body{overflow:auto;padding:18px;flex:1}
.close-x{background:var(--chip);border:1px solid var(--border);color:var(--text);width:30px;height:30px;
border-radius:8px;font-size:16px;line-height:1}
.section{margin-bottom:22px}
.section h3{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);
margin:0 0 10px;display:flex;align-items:center;gap:8px}
.pre{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;
font-family:var(--mono);font-size:12.5px;white-space:pre-wrap;word-break:break-word;color:var(--text);max-height:340px;overflow:auto}
.card-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px}
.card-list li{background:var(--bg);border:1px solid var(--border);border-left:3px solid var(--accent);
border-radius:6px;padding:9px 12px;font-size:13px}
/* Chat */
.msg{display:flex;gap:10px;margin-bottom:14px;max-width:100%}
.msg .avatar{margin-top:2px}
.msg .bubble{background:var(--panel2);border:1px solid var(--border);border-radius:10px;padding:9px 13px;
max-width:78%;min-width:0}
.msg .who{font-size:11.5px;font-weight:600;margin-bottom:3px}
.msg .text{white-space:pre-wrap;word-break:break-word;font-size:13px}
.msg .when{font-size:10.5px;color:var(--muted);margin-top:5px}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--red);color:#fff;
padding:10px 16px;border-radius:8px;font-size:13px;z-index:50;display:none;max-width:80%}
.spinner{width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--accent);
border-radius:50%;animation:spin .7s linear infinite;display:inline-block;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.loading{color:var(--muted);padding:30px;text-align:center}
.hint{font-size:11.5px;color:var(--muted)}
</style>
</head>
<body>
<header>
<h1><span class="brand-emoji">🧠</span> Honcho Memory</h1>
<div class="conn">
<input class="base" id="baseUrl" placeholder="https://your-space.hf.space" spellcheck="false" />
<input class="key" id="apiKey" placeholder="API key (optional)" spellcheck="false" />
<button class="btn" id="connectBtn">Connect</button>
<span class="dot" id="connDot"></span>
<span class="status-text" id="connText">not connected</span>
</div>
</header>
<main>
<aside>
<h2>Workspaces</h2>
<div id="wsList"><div class="hint" style="padding:8px">Connect to load workspaces…</div></div>
</aside>
<div class="content" id="content">
<div class="empty-pane">
<div class="big">🧠</div>
<div>Connect to your Honcho instance, then pick a workspace.</div>
</div>
</div>
</main>
<!-- Detail drawer -->
<div class="overlay" id="overlay"></div>
<div class="drawer" id="drawer">
<header>
<div class="dtitle" id="drawerTitle">—</div>
<button class="close-x" id="drawerClose">×</button>
</header>
<div class="body" id="drawerBody"></div>
</div>
<div class="toast" id="toast"></div>
<script>
"use strict";
// When served by the Honcho app itself, default to its own origin (same-origin, no CORS).
// When opened as a local file, fall back to the public Space URL.
const DEFAULT_BASE = location.protocol.startsWith("http")
? location.origin
: "https://tareek-honcho-api.hf.space";
const $ = (id)=>document.getElementById(id);
const state = {
base: localStorage.getItem("honcho.base") || DEFAULT_BASE,
key: localStorage.getItem("honcho.key") || "",
workspace: null,
tab: "peers",
queueTimer: null,
};
// ---------- API helpers ----------
function headers(){
const h = {"Content-Type":"application/json"};
if(state.key) h["Authorization"] = "Bearer " + state.key;
return h;
}
async function api(path, {method="GET", body=null}={}){
const url = state.base.replace(/\/+$/,"") + path;
const res = await fetch(url, {
method,
headers: headers(),
body: body!=null ? JSON.stringify(body) : null,
});
if(!res.ok){
let detail = res.statusText;
try{ const j = await res.json(); detail = j.detail || JSON.stringify(j); }catch{}
throw new Error(`${res.status} ${detail}`);
}
if(res.status===204) return null;
return res.json();
}
// POST-based listing helper (fastapi-pagination). size=100 to fit small datasets in one page.
function listAll(path, body={}){
return api(path + "?page=1&size=100", {method:"POST", body});
}
// ---------- utils ----------
const colors = ["#a371f7","#58a6ff","#3fb950","#d29922","#f85149","#db61a2","#39c5cf","#bc8cff"];
function colorFor(s){ let h=0; for(const c of String(s)) h=(h*31+c.charCodeAt(0))>>>0; return colors[h%colors.length]; }
function initial(s){ return String(s||"?").trim().charAt(0).toUpperCase() || "?"; }
function fmtDate(iso){ if(!iso) return ""; const d=new Date(iso); if(isNaN(d)) return iso;
return d.toLocaleString(undefined,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}); }
function esc(s){ return String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
function toast(msg){ const t=$("toast"); t.textContent=msg; t.style.display="block";
clearTimeout(toast._t); toast._t=setTimeout(()=>t.style.display="none",5000); }
function avatar(id){ return `<span class="avatar" style="background:${colorFor(id)}">${esc(initial(id))}</span>`; }
// ---------- connect / workspaces ----------
async function connect(){
state.base = $("baseUrl").value.trim() || DEFAULT_BASE;
state.key = $("apiKey").value.trim();
localStorage.setItem("honcho.base", state.base);
localStorage.setItem("honcho.key", state.key);
setConn("connecting…", null);
try{
await api("/health");
setConn("connected", true);
await loadWorkspaces();
}catch(e){
setConn("failed", false);
toast("Connection failed: " + e.message);
}
}
function setConn(text, ok){
$("connText").textContent = text;
const d=$("connDot"); d.className = "dot" + (ok===true?" ok":ok===false?" err":"");
}
async function loadWorkspaces(){
const data = await listAll("/v3/workspaces/list");
const items = data.items||[];
const el = $("wsList");
if(!items.length){ el.innerHTML = `<div class="hint" style="padding:8px">No workspaces.</div>`; return; }
el.innerHTML = items.map(w=>`
<div class="ws-item" data-id="${esc(w.id)}">
<span class="name">${esc(w.id)}</span>
<span class="meta">${fmtDate(w.created_at)}</span>
</div>`).join("");
el.querySelectorAll(".ws-item").forEach(n=>n.onclick=()=>selectWorkspace(n.dataset.id));
}
function selectWorkspace(id){
state.workspace = id;
document.querySelectorAll(".ws-item").forEach(n=>n.classList.toggle("active", n.dataset.id===id));
renderWorkspace();
}
// ---------- workspace view ----------
async function renderWorkspace(){
$("content").innerHTML = `
<div id="queueBox"></div>
<div class="tabs">
<button class="tab" data-tab="peers">Peers</button>
<button class="tab" data-tab="sessions">Sessions</button>
<button class="tab" data-tab="conclusions">Conclusions</button>
</div>
<div id="tabBody"><div class="loading"><span class="spinner"></span></div></div>`;
document.querySelectorAll(".tab").forEach(t=>t.onclick=()=>setTab(t.dataset.tab));
setTab(state.tab);
startQueuePolling();
}
function setTab(tab){
state.tab = tab;
document.querySelectorAll(".tab").forEach(t=>t.classList.toggle("active", t.dataset.tab===tab));
if(tab==="peers") renderPeers();
else if(tab==="sessions") renderSessions();
else renderConclusions();
}
// ---------- queue status (live polling) ----------
function startQueuePolling(){
if(state.queueTimer) clearInterval(state.queueTimer);
const tick = async ()=>{
if(!state.workspace) return;
const ws = state.workspace;
try{
const q = await api(`/v3/workspaces/${encodeURIComponent(ws)}/queue/status`);
if(state.workspace===ws) renderQueue(q);
}catch(e){ /* non-fatal */ }
};
tick();
state.queueTimer = setInterval(tick, 7000);
}
function renderQueue(q){
const box = $("queueBox"); if(!box) return;
const total = q.total_work_units||0, done=q.completed_work_units||0,
prog=q.in_progress_work_units||0, pend=q.pending_work_units||0;
const pDone = total? (done/total*100):0, pProg = total?(prog/total*100):0;
const active = pend>0 || prog>0;
box.innerHTML = `
<div class="queue">
<div class="row">
<div class="title">${active?'<span class="spinner"></span>':'✓'} Deriver queue
<span class="badge ${active?'':'green'}">${active?'deriving memory…':'idle'}</span></div>
<div class="nums">${done}/${total} work units</div>
</div>
<div class="bar">
<div class="done" style="width:${pDone}%"></div>
<div class="prog ${prog?'pulse':''}" style="width:${pProg}%"></div>
</div>
<div class="legend">
<span><b>${done}</b> completed</span>
<span><b>${prog}</b> in progress</span>
<span><b>${pend}</b> pending</span>
<span><b>${Object.keys(q.sessions||{}).length}</b> sessions in queue</span>
</div>
</div>`;
}
// ---------- Peers ----------
async function renderPeers(){
const ws = state.workspace, body=$("tabBody");
body.innerHTML = `<div class="loading"><span class="spinner"></span></div>`;
try{
const data = await listAll(`/v3/workspaces/${encodeURIComponent(ws)}/peers/list`);
if(state.workspace!==ws) return;
const items = data.items||[];
tabCount("peers", items.length);
if(!items.length){ body.innerHTML = emptyState("No peers in this workspace yet."); return; }
body.innerHTML = `<div class="grid">${items.map(p=>`
<div class="card" data-peer="${esc(p.id)}">
<div class="h">${avatar(p.id)} ${esc(p.id)}</div>
<div class="sub">created ${fmtDate(p.created_at)}</div>
<div class="sub">↳ click to view derived memory</div>
</div>`).join("")}</div>`;
body.querySelectorAll(".card").forEach(n=>n.onclick=()=>openPeer(n.dataset.peer));
}catch(e){ body.innerHTML = errState(e); }
}
async function openPeer(peerId){
const ws = state.workspace;
openDrawer(`${avatar(peerId)} ${esc(peerId)}`, `<div class="loading"><span class="spinner"></span></div>`);
try{
const [card, rep] = await Promise.all([
api(`/v3/workspaces/${encodeURIComponent(ws)}/peers/${encodeURIComponent(peerId)}/card`).catch(()=>({})),
api(`/v3/workspaces/${encodeURIComponent(ws)}/peers/${encodeURIComponent(peerId)}/representation`,
{method:"POST", body:{max_conclusions:50, include_most_frequent:true}}).catch(()=>({})),
]);
const cardArr = (card && card.peer_card) || [];
const repText = (rep && typeof rep.representation==="string") ? rep.representation
: (rep && rep.representation!=null) ? JSON.stringify(rep.representation,null,2) : "";
$("drawerBody").innerHTML = `
<div class="section">
<h3>🪪 Peer card <span class="hint">(${cardArr.length})</span></h3>
${cardArr.length
? `<ul class="card-list">${cardArr.map(c=>`<li>${esc(c)}</li>`).join("")}</ul>`
: emptyInline("No peer card facts yet.")}
</div>
<div class="section">
<h3>🧬 Representation <span class="hint">(derived memory)</span></h3>
${repText.trim()
? `<div class="pre">${esc(repText)}</div>`
: emptyInline("Representation is empty — the deriver hasn't produced conclusions for this peer yet. Watch the queue above; it fills in as pending work units complete.")}
</div>`;
}catch(e){ $("drawerBody").innerHTML = errState(e); }
}
// ---------- Sessions ----------
async function renderSessions(){
const ws = state.workspace, body=$("tabBody");
body.innerHTML = `<div class="loading"><span class="spinner"></span></div>`;
try{
const data = await listAll(`/v3/workspaces/${encodeURIComponent(ws)}/sessions/list`);
if(state.workspace!==ws) return;
const items = data.items||[];
tabCount("sessions", items.length);
if(!items.length){ body.innerHTML = emptyState("No sessions in this workspace yet."); return; }
body.innerHTML = items.map(s=>`
<div class="session-row" data-sid="${esc(s.id)}">
<div class="left">
<span class="sid">${esc(s.id)}</span>
<span class="sub">created ${fmtDate(s.created_at)}</span>
</div>
<span class="badge ${s.is_active?'green':''}">${s.is_active?'active':'inactive'}</span>
</div>`).join("");
body.querySelectorAll(".session-row").forEach(n=>n.onclick=()=>openSession(n.dataset.sid));
}catch(e){ body.innerHTML = errState(e); }
}
async function openSession(sid){
const ws = state.workspace;
openDrawer(`💬 ${esc(sid)}`, `<div class="loading"><span class="spinner"></span></div>`);
try{
const [msgs, summaries] = await Promise.all([
listAll(`/v3/workspaces/${encodeURIComponent(ws)}/sessions/${encodeURIComponent(sid)}/messages/list`),
api(`/v3/workspaces/${encodeURIComponent(ws)}/sessions/${encodeURIComponent(sid)}/summaries`).catch(()=>null),
]);
const items = msgs.items||[];
let html = "";
if(summaries && (Array.isArray(summaries)? summaries.length : Object.keys(summaries).length)){
html += `<div class="section"><h3>📝 Summaries</h3>
<div class="pre">${esc(JSON.stringify(summaries,null,2))}</div></div>`;
}
html += `<div class="section"><h3>💬 Messages <span class="hint">(${items.length})</span></h3>`;
if(!items.length){ html += emptyInline("No messages in this session."); }
else {
html += items.map(m=>`
<div class="msg">
${avatar(m.peer_id)}
<div class="bubble">
<div class="who" style="color:${colorFor(m.peer_id)}">${esc(m.peer_id)}</div>
<div class="text">${esc(m.content)}</div>
<div class="when">${fmtDate(m.created_at)}${m.token_count?` · ${m.token_count} tok`:""}</div>
</div>
</div>`).join("");
}
html += `</div>`;
$("drawerBody").innerHTML = html;
}catch(e){ $("drawerBody").innerHTML = errState(e); }
}
// ---------- Conclusions ----------
async function renderConclusions(){
const ws = state.workspace, body=$("tabBody");
body.innerHTML = `<div class="loading"><span class="spinner"></span></div>`;
try{
const data = await listAll(`/v3/workspaces/${encodeURIComponent(ws)}/conclusions/list`);
if(state.workspace!==ws) return;
const items = data.items||[];
tabCount("conclusions", data.total ?? items.length);
if(!items.length){
body.innerHTML = emptyState("No conclusions derived yet.",
"Conclusions are the structured memory the deriver extracts from messages. They appear here as the queue (above) drains its pending work units.");
return;
}
body.innerHTML = `<ul class="card-list">${items.map(c=>{
const text = c.content || c.conclusion || c.text || JSON.stringify(c);
const meta = [c.peer_id, c.session_id, fmtDate(c.created_at)].filter(Boolean).join(" · ");
return `<li><div>${esc(text)}</div>${meta?`<div class="sub" style="margin-top:5px">${esc(meta)}</div>`:""}</li>`;
}).join("")}</ul>`;
}catch(e){ body.innerHTML = errState(e); }
}
// ---------- shared UI bits ----------
function tabCount(tab, n){ const t=document.querySelector(`.tab[data-tab="${tab}"]`);
if(t && !t.querySelector(".count")) t.insertAdjacentHTML("beforeend",` <span class="count">${n}</span>`);
else if(t) t.querySelector(".count").textContent = n; }
function emptyState(title, sub=""){ return `<div class="empty-pane" style="min-height:200px">
<div class="big">∅</div><div><b>${esc(title)}</b></div>${sub?`<div class="hint" style="max-width:440px">${esc(sub)}</div>`:""}</div>`; }
function emptyInline(msg){ return `<div class="hint" style="padding:12px;background:var(--bg);border:1px dashed var(--border);border-radius:8px">${esc(msg)}</div>`; }
function errState(e){ return `<div class="empty-pane" style="min-height:160px"><div class="big">⚠️</div>
<div><b>Error</b></div><div class="hint">${esc(e.message||e)}</div></div>`; }
function openDrawer(title, body){ $("drawerTitle").innerHTML=title; $("drawerBody").innerHTML=body;
$("drawer").classList.add("open"); $("overlay").classList.add("open"); }
function closeDrawer(){ $("drawer").classList.remove("open"); $("overlay").classList.remove("open"); }
// ---------- wire up ----------
$("baseUrl").value = state.base;
$("apiKey").value = state.key;
$("connectBtn").onclick = connect;
$("baseUrl").addEventListener("keydown",e=>{if(e.key==="Enter")connect();});
$("apiKey").addEventListener("keydown",e=>{if(e.key==="Enter")connect();});
$("drawerClose").onclick = closeDrawer;
$("overlay").onclick = closeDrawer;
document.addEventListener("keydown",e=>{if(e.key==="Escape")closeDrawer();});
// auto-connect on load
connect();
</script>
</body>
</html>