fca13c4c89
A pull-up-anytime view of Lyra's interiority, so her thoughts aren't buried in a DB blob. Mobile-first, auto-refreshing every 12s (and on tab focus). - GET /self serves the page; GET /self/state returns her self-state + the timestamp it last changed - shows: current mood + feeling meters (valence/energy/confidence/curiosity), her drives as bars, her self-narrative, the relationship line, and the reflections list (newest first), plus cycle/reflection counters and "last cycle Xm ago" - memory.self_state_updated_at(): when her mind last changed - index.html: "🧠 Mind" button opens /self Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
7.2 KiB
HTML
173 lines
7.2 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||
<meta name="theme-color" content="#0b0d12" />
|
||
<title>Lyra — Mind</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0b0d12; --bg-elev: #141821; --bg-line: #11141b; --border: #232936;
|
||
--text: #e6e9ef; --fade: #8b93a7; --accent: #7aa2ff;
|
||
--good: #5ad1a0; --mid: #ffcf6b; --low: #ff6b6b; --violet: #c08bff;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
margin: 0; min-height: 100%; background: var(--bg); color: var(--text);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
-webkit-text-size-adjust: 100%;
|
||
}
|
||
header {
|
||
position: sticky; top: 0; z-index: 10; background: var(--bg-elev);
|
||
border-bottom: 1px solid var(--border); padding: env(safe-area-inset-top) 14px 0;
|
||
}
|
||
.topbar { display: flex; align-items: center; gap: 10px; padding: 13px 0 12px; }
|
||
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
|
||
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
|
||
.updated { margin-left: auto; color: var(--fade); font-size: .78rem; }
|
||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); flex: none; opacity: .35; transition: opacity .2s; }
|
||
.dot.pulse { opacity: 1; }
|
||
|
||
main { max-width: 680px; margin: 0 auto; padding: 16px 14px 40px; }
|
||
.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; padding: 16px; margin-bottom: 14px; }
|
||
.label { color: var(--fade); font-size: .72rem; text-transform: uppercase; letter-spacing: .6px; margin: 0 0 10px; }
|
||
|
||
.mood-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||
.mood { font-size: 2.1rem; font-weight: 700; letter-spacing: .2px; }
|
||
.mood-sub { color: var(--fade); font-size: .9rem; }
|
||
|
||
.meter { margin: 11px 0; }
|
||
.meter-top { display: flex; justify-content: space-between; font-size: .85rem; margin-bottom: 5px; }
|
||
.meter-top .v { color: var(--fade); font-variant-numeric: tabular-nums; }
|
||
.track { height: 8px; background: var(--bg-line); border-radius: 999px; overflow: hidden; }
|
||
.fill { height: 100%; border-radius: 999px; transition: width .5s ease; }
|
||
|
||
.prose { font-size: 1.02rem; line-height: 1.6; margin: 0; }
|
||
.prose.rel { color: var(--text); opacity: .92; }
|
||
|
||
ul.reflections { list-style: none; margin: 0; padding: 0; }
|
||
ul.reflections li {
|
||
position: relative; padding: 10px 0 10px 18px; border-bottom: 1px solid var(--bg-line);
|
||
font-size: .98rem; line-height: 1.5;
|
||
}
|
||
ul.reflections li:last-child { border-bottom: none; }
|
||
ul.reflections li::before { content: "›"; position: absolute; left: 2px; color: var(--violet); font-weight: 700; }
|
||
|
||
.foot { display: flex; flex-wrap: wrap; gap: 14px; color: var(--fade); font-size: .82rem; padding: 4px 2px; }
|
||
.foot b { color: var(--text); font-weight: 600; }
|
||
.err { color: var(--low); text-align: center; padding: 30px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="topbar">
|
||
<span class="dot" id="dot"></span>
|
||
<h1>🧠 Lyra · Mind</h1>
|
||
<a class="back" href="/">← Chat</a>
|
||
<span class="updated" id="updated">—</span>
|
||
</div>
|
||
</header>
|
||
<main id="root"><p class="err" id="boot">Reading her mind…</p></main>
|
||
|
||
<script>
|
||
const root = document.getElementById('root');
|
||
const dot = document.getElementById('dot');
|
||
const updatedEl = document.getElementById('updated');
|
||
let lastStamp = null;
|
||
|
||
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
|
||
function pct(v){ return Math.round(Math.max(0, Math.min(1, Number(v)||0)) * 100); }
|
||
function color(v){ v=Number(v)||0; return v >= .6 ? 'var(--good)' : v >= .35 ? 'var(--mid)' : 'var(--low)'; }
|
||
|
||
function ago(iso){
|
||
if(!iso) return '—';
|
||
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
|
||
if(s < 60) return 'just now';
|
||
if(s < 3600) return Math.round(s/60)+'m ago';
|
||
if(s < 86400) return Math.round(s/3600)+'h ago';
|
||
return Math.round(s/86400)+'d ago';
|
||
}
|
||
|
||
function meter(name, v){
|
||
return `<div class="meter">
|
||
<div class="meter-top"><span>${esc(name)}</span><span class="v">${pct(v)}%</span></div>
|
||
<div class="track"><div class="fill" style="width:${pct(v)}%;background:${color(v)}"></div></div>
|
||
</div>`;
|
||
}
|
||
|
||
function render(data){
|
||
const s = data.state || {};
|
||
const d = s.drives || {};
|
||
const dream = s.dream || {};
|
||
const refl = (s.reflections || []).slice().reverse();
|
||
|
||
root.innerHTML = `
|
||
<div class="card">
|
||
<div class="mood-row">
|
||
<span class="mood">${esc(s.mood || '—')}</span>
|
||
<span class="mood-sub">how she's feeling right now</span>
|
||
</div>
|
||
${meter('valence (how good she feels)', s.valence)}
|
||
${meter('energy', s.energy)}
|
||
${meter('confidence', s.confidence)}
|
||
${meter('curiosity', s.curiosity)}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<p class="label">Drives — what's pulling at her</p>
|
||
${meter('continuity (don\\'t lose the thread)', d.continuity)}
|
||
${meter('coherence (keep her understanding current)', d.coherence)}
|
||
${meter('curiosity (urge to think / reflect)', d.curiosity)}
|
||
${meter('stability (how settled she is)', d.stability)}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<p class="label">Who she is right now</p>
|
||
<p class="prose">${esc(s.self_narrative || '—')}</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<p class="label">You & her</p>
|
||
<p class="prose rel">${esc(s.relationship || '—')}</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<p class="label">On her mind (newest first)</p>
|
||
${refl.length
|
||
? `<ul class="reflections">${refl.map(r => `<li>${esc(r)}</li>`).join('')}</ul>`
|
||
: `<p class="prose" style="color:var(--fade)">Nothing surfaced yet.</p>`}
|
||
</div>
|
||
|
||
<div class="foot">
|
||
<span><b>${dream.cycle_count ?? 0}</b> dream cycles</span>
|
||
<span><b>${s.interaction_count ?? 0}</b> reflections</span>
|
||
<span>last cycle <b>${ago(dream.last_cycle_at)}</b></span>
|
||
</div>
|
||
`;
|
||
updatedEl.textContent = 'thought ' + ago(data.updated_at);
|
||
}
|
||
|
||
async function refresh(){
|
||
try {
|
||
const r = await fetch('/self/state', { cache: 'no-store' });
|
||
const data = await r.json();
|
||
dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400);
|
||
// only re-render if something actually changed (avoids flicker)
|
||
if (data.updated_at !== lastStamp || lastStamp === null) {
|
||
lastStamp = data.updated_at;
|
||
render(data);
|
||
} else {
|
||
updatedEl.textContent = 'thought ' + ago(data.updated_at);
|
||
}
|
||
} catch (e) {
|
||
if (!lastStamp) root.innerHTML = '<p class="err">Couldn\'t reach her. Is the server up?</p>';
|
||
}
|
||
}
|
||
|
||
refresh();
|
||
setInterval(refresh, 12000);
|
||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
|
||
</script>
|
||
</body>
|
||
</html>
|