Spaces:
Paused
Paused
Download src/static/dashboard.html from tareek/honcho-api: direct link, hf CLI and curl.
- Browser
- Download file 23.1 kB
-
https://huggingface.co/spaces/tareek/honcho-api/resolve/main/src/static/dashboard.html
- Command line
-
hf download hf://spaces/tareek/honcho-api/src/static/dashboard.html
-
curl -L -o dashboard.html https://huggingface.co/spaces/tareek/honcho-api/resolve/main/src/static/dashboard.html
23.1 kB
| <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> | |
| ; | |
| // 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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> | |