feat(web): "read her mind" — live self-state page
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>
This commit is contained in:
@@ -512,6 +512,15 @@ def get_self_state(state_id: str = "lyra") -> dict | None:
|
||||
return json.loads(r["data"]) if r else None
|
||||
|
||||
|
||||
def self_state_updated_at(state_id: str = "lyra") -> str | None:
|
||||
"""ISO timestamp her self-state was last written (None if never)."""
|
||||
conn = _connection()
|
||||
r = conn.execute(
|
||||
"SELECT updated_at FROM self_state WHERE id = ?", (state_id,)
|
||||
).fetchone()
|
||||
return r["updated_at"] if r else None
|
||||
|
||||
|
||||
def set_self_state(state: dict, state_id: str = "lyra") -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = _connection()
|
||||
|
||||
+11
-1
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lyra import chat, logbus, memory, summary
|
||||
from lyra import chat, logbus, memory, self_state, summary
|
||||
from lyra.llm import Backend
|
||||
|
||||
|
||||
@@ -115,6 +115,16 @@ def create_app() -> FastAPI:
|
||||
"""Full-page, mobile-friendly live log viewer (separate from the chat UI)."""
|
||||
return FileResponse(str(_STATIC / "logs.html"))
|
||||
|
||||
@app.get("/self")
|
||||
async def self_page() -> FileResponse:
|
||||
"""'Read her mind' — a view of Lyra's current self-state."""
|
||||
return FileResponse(str(_STATIC / "self.html"))
|
||||
|
||||
@app.get("/self/state")
|
||||
async def self_state_json() -> dict:
|
||||
"""Lyra's current interiority + when it last changed."""
|
||||
return {"state": self_state.load(), "updated_at": memory.self_state_updated_at()}
|
||||
|
||||
@app.get("/stream/logs")
|
||||
async def stream_logs(request: Request) -> StreamingResponse:
|
||||
"""Live activity feed: replay the recent buffer, then stream new events."""
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
<button id="renameSessionBtn">✏️ Rename</button>
|
||||
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
|
||||
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
|
||||
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user